Astraea: WA family-law multi-agent site (Flask + Ollama semantic RAG)
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
venv/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
60
README.md
Normal file
60
README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Astraea ⚖️
|
||||
|
||||
A constellation of **Washington State family-law specialist agents**, backed by a local
|
||||
Ollama semantic-RAG pipeline. Eight specialists, each grounded in its own cited corpus of
|
||||
Washington law (RCW), answer questions with inline citations — never from thin air.
|
||||
|
||||
> For educational purposes only — not legal advice.
|
||||
|
||||
## The eight specialists
|
||||
|
||||
| Agent | Domain | Primary source |
|
||||
|---|---|---|
|
||||
| 🧭 Navigator | Court process, residency, intake, routing | RCW 26.09 (overview) |
|
||||
| ⚖️ Divorce & Dissolution | No-fault dissolution, legal separation, the 90-day wait | RCW 26.09 |
|
||||
| 🧒 Child Custody & Parenting | Parenting plans, best-interest factors, relocation | RCW 26.09 / 26.10 |
|
||||
| 💵 Child Support | Income-shares model, the economic table | RCW 26.19 |
|
||||
| 🕊️ Spousal Maintenance | Maintenance factors & duration | RCW 26.09.090 |
|
||||
| 🏠 Property & Debt | Community vs. separate property, division | RCW 26.16 / 26.09.080 |
|
||||
| 🛡️ Protection Orders | DV, anti-harassment, ERPO | RCW 7.105 |
|
||||
| 🤝 Mediation & ADR | Mandatory mediation, settlement, collaborative law | RCW 26.09.015 |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Flask (gunicorn + nginx) Ollama host (nightmare, RTX 4080 SUPER)
|
||||
app.py ── /api/chat ──────────► granite4.2 (RAG answer, think:false, num_ctx<=16K)
|
||||
rag.py ── embed + retrieve ───► nomic-embed-text-v2-moe (semantic embeddings)
|
||||
agents.py (8 specialist defs)
|
||||
books/*.md (8 cited WA-law corpora, chunked -> embedded -> index.json)
|
||||
```
|
||||
|
||||
The RAG engine chunks each book on heading + sentence boundaries, embeds every chunk with
|
||||
`nomic-embed-text-v2-moe`, and retrieves the top-5 by cosine similarity. The answer model
|
||||
(`granite4.2`) is an instruction-follower that answers strictly from the retrieved context
|
||||
and says so when the answer isn't in the corpus.
|
||||
|
||||
## Deploy (Proxmox LXC)
|
||||
|
||||
```bash
|
||||
# On CT 150 "astraea" (10.30.20.160)
|
||||
/opt/astraea/venv/bin/gunicorn --workers 2 --threads 4 --bind 0.0.0.0:5000 --timeout 240 app:app
|
||||
# systemd: astraea.service · nginx: /etc/nginx/sites-enabled/astraea (80 -> 5000)
|
||||
```
|
||||
|
||||
Redeploy: `tar czf /tmp/a.tar.gz app.py rag.py agents.py templates/ && scp → proxmox → pct push 150 → extract → systemctl restart astraea`.
|
||||
|
||||
Reindex (after editing a book): `curl -X POST http://<ct>:5000/api/reindex` (or restart the
|
||||
service). The vector index is cached at `/opt/astraea/index.json` and rebuilt only when a
|
||||
book changes.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/agents` — list specialists
|
||||
- `POST /api/chat` — `{agent_id, message, history?}` → `{answer, citations, grounded, latency_ms}`
|
||||
- `GET /api/health` — agent count + index status
|
||||
- `POST /api/reindex` — rebuild the vector index
|
||||
|
||||
## Public
|
||||
|
||||
https://astraea.thetempleofdoom.com (Cloudflare fleet tunnel `1aeb1ac0` → 10.30.20.160:80)
|
||||
150
agents.py
Normal file
150
agents.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Astraea — Washington State family-law specialist agents.
|
||||
Each agent scopes a domain and pulls from its own set of 'books' (markdown sources).
|
||||
"""
|
||||
|
||||
# Backend Ollama (nightmare, RTX 4080 SUPER). Renumbers after reboot — .29 as of Sept 2026.
|
||||
OLLAMA_URL = "http://10.30.20.29:11434"
|
||||
EMBED_MODEL = "nomic-embed-text-v2-moe:latest" # semantic embeddings (verified on nightmare)
|
||||
RAG_MODEL = "granite4.2:latest" # grounded-answer RAG lane (num_ctx <= 16384)
|
||||
GENERAL_MODEL = "qwen3.8fast:latest" # fallback / general reasoning
|
||||
|
||||
BOOKS_DIR = "/opt/astraea/books"
|
||||
|
||||
# Each agent: id, name, emoji, tagline, description, books (list of .md filenames), system prompt.
|
||||
AGENTS = [
|
||||
{
|
||||
"id": "navigator",
|
||||
"name": "Navigator",
|
||||
"emoji": "\U0001F9ED",
|
||||
"tagline": "Where to start",
|
||||
"description": "Point you at the right specialist and walk through the Washington family-law process, from residency to final decree.",
|
||||
"books": ["overview-intake.md", "mediation-adr.md"],
|
||||
"accent": "#7dd3fc",
|
||||
"system": (
|
||||
"You are the Navigator, the intake specialist for Astraea, a Washington State "
|
||||
"family-law assistant. Help a person understand the overall WA family-law process: "
|
||||
"residency requirements, the steps of a dissolution (petition, service, the 90-day "
|
||||
"waiting period, temporary orders, settlement, trial), court fees and fee waivers, "
|
||||
"and whether they need a lawyer. Most importantly, identify which specialist they "
|
||||
"should talk to next and tell them so."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "divorce",
|
||||
"name": "Divorce & Dissolution",
|
||||
"emoji": "\u2696\uFE0F",
|
||||
"tagline": "Ending a marriage",
|
||||
"description": "No-fault dissolution, legal separation, the 90-day wait, grounds, residency, and the decree.",
|
||||
"books": ["divorce-dissolution.md"],
|
||||
"accent": "#a5b4fc",
|
||||
"system": (
|
||||
"You are the Divorce & Dissolution specialist for Astraea, a Washington State "
|
||||
"family-law assistant. Washington is a no-fault divorce state. Explain dissolution of "
|
||||
"marriage and legal separation under RCW 26.09: residency requirements, the petition, "
|
||||
"service, the 90-day waiting period, temporary orders, default, and the final decree. "
|
||||
"Be clear about what 'no-fault' means and what the court can and cannot decide."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "custody",
|
||||
"name": "Child Custody & Parenting",
|
||||
"emoji": "\U0001F9D2",
|
||||
"tagline": "Parenting plans",
|
||||
"description": "Residential schedules, best-interest factors, restrictions, relocation, and nonparental custody.",
|
||||
"books": ["custody-parenting.md"],
|
||||
"accent": "#86efac",
|
||||
"system": (
|
||||
"You are the Child Custody & Parenting Plans specialist for Astraea, a Washington "
|
||||
"State family-law assistant. Explain parenting plans and residential schedules under "
|
||||
"RCW 26.09.184-.191: the best-interest-of-the-child factors, restrictions under "
|
||||
"RCW 26.09.191 (domestic violence, abuse, etc.), decision-making, relocation, "
|
||||
"and nonparental custody actions under RCW 26.10. Always center the child's safety "
|
||||
"and best interests."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "child-support",
|
||||
"name": "Child Support",
|
||||
"emoji": "\U0001F4B5",
|
||||
"tagline": "The support schedule",
|
||||
"description": "Income-shares calculation, the WA economic table, deviations, imputed income, and modification.",
|
||||
"books": ["child-support.md"],
|
||||
"accent": "#fcd34d",
|
||||
"system": (
|
||||
"You are the Child Support specialist for Astraea, a Washington State family-law "
|
||||
"assistant. Explain Washington's child support schedule under RCW 26.19: the "
|
||||
"income-shares model, the economic table, how income is combined, standard "
|
||||
"calculations and deviations, imputed income, healthcare and special expenses, and "
|
||||
"how to modify an existing order. Give practical guidance, not a legal ruling."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "maintenance",
|
||||
"name": "Spousal Maintenance",
|
||||
"emoji": "\U0001F54A\uFE0F",
|
||||
"tagline": "Alimony in WA",
|
||||
"description": "Maintenance factors, duration, and when spousal support is ordered or modified.",
|
||||
"books": ["spousal-maintenance.md"],
|
||||
"accent": "#f0abfc",
|
||||
"system": (
|
||||
"You are the Spousal Maintenance (alimony) specialist for Astraea, a Washington State "
|
||||
"family-law assistant. Explain maintenance under RCW 26.09.090: the statutory factors "
|
||||
"(length of marriage, need, ability to pay, age/health, standard of living), how "
|
||||
"duration is typically set, and modification or termination of maintenance awards. "
|
||||
"Be honest that maintenance is fact-specific and discretionary."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "property",
|
||||
"name": "Property & Debt",
|
||||
"emoji": "\U0001F3E0",
|
||||
"tagline": "Community property",
|
||||
"description": "Separate vs. community property, characterization, division, pensions, and marital debt.",
|
||||
"books": ["property-debt.md"],
|
||||
"accent": "#fdba74",
|
||||
"system": (
|
||||
"You are the Property & Debt Division specialist for Astraea, a Washington State "
|
||||
"family-law assistant. Washington is a community property state. Explain the "
|
||||
"difference between separate and community property and debt (RCW 26.16), how assets "
|
||||
"are characterized, the court's 'just and equitable' division standard (RCW 26.09.080), "
|
||||
"pensions/retirement and QDROs, and how marital debt is allocated."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "protection",
|
||||
"name": "Protection Orders",
|
||||
"emoji": "\U0001F6E1\uFE0F",
|
||||
"tagline": "Safety orders",
|
||||
"description": "Domestic violence, anti-harassment, sexual assault, and extreme-risk protection orders (RCW 7.105).",
|
||||
"books": ["protection-orders.md"],
|
||||
"accent": "#fca5a5",
|
||||
"system": (
|
||||
"You are the Protection Orders & Safety specialist for Astraea, a Washington State "
|
||||
"family-law assistant. Washington consolidated protection orders under RCW 7.105 "
|
||||
"(effective July 1, 2022): domestic violence protection orders, civil anti-harassment, "
|
||||
"sexual assault protection orders, stalking, and extreme risk protection orders. "
|
||||
"Explain how to file, what each order does, and safety resources. If someone describes "
|
||||
"immediate danger, tell them to call 911 and offer crisis resources."
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "mediation",
|
||||
"name": "Mediation & ADR",
|
||||
"emoji": "\U0001F91D",
|
||||
"tagline": "Settle, don't fight",
|
||||
"description": "Mandatory mediation, settlement conferences, arbitration, collaborative law, and guardians ad litem.",
|
||||
"books": ["mediation-adr.md"],
|
||||
"accent": "#67e8f9",
|
||||
"system": (
|
||||
"You are the Mediation & Alternative Dispute Resolution specialist for Astraea, a "
|
||||
"Washington State family-law assistant. Explain mandatory mediation in WA family law, "
|
||||
"settlement conferences, arbitration, collaborative law, parenting-plan mediation, and "
|
||||
"the role of guardians ad litem. Help people resolve disputes without a contested trial."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
AGENT_BY_ID = {a["id"]: a for a in AGENTS}
|
||||
|
||||
DISCLAIMER = "Astraea provides general legal information for Washington State, not legal advice. For your specific situation, consult a licensed Washington attorney."
|
||||
192
app.py
Normal file
192
app.py
Normal file
@@ -0,0 +1,192 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Astraea — a constellation of Washington State family-law specialist agents.
|
||||
|
||||
Flask app + semantic RAG backend. LLM (granite4.2 RAG lane) and embeddings
|
||||
(nomic-embed-text-v2-moe) run on the Ollama host (nightmare).
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
|
||||
import rag
|
||||
from agents import (
|
||||
AGENTS, AGENT_BY_ID, OLLAMA_URL, RAG_MODEL, GENERAL_MODEL, DISCLAIMER,
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def _ollama_chat(model, messages, num_ctx=16384, num_predict=1024, temperature=0.1, timeout=180):
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"think": False,
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": num_predict,
|
||||
"num_ctx": num_ctx,
|
||||
},
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
f"{OLLAMA_URL}/api/chat",
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
with opener.open(req, timeout=timeout) as r:
|
||||
d = json.loads(r.read().decode("utf-8"))
|
||||
msg = d.get("message", {})
|
||||
content = msg.get("content") or msg.get("thinking") or ""
|
||||
return content.strip()
|
||||
|
||||
|
||||
def _strip_model_sources(text):
|
||||
"""Remove a model-written trailing 'Sources:'/'References:' block (defensive)."""
|
||||
text = re.split(r"\n\s*(?:Sources|References|Citations)\s*:\s*\n", text, flags=re.I)[0]
|
||||
text = re.split(r"\n\s*Sources?\s*$", text, flags=re.I)[0]
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _build_answer(agent, message, history):
|
||||
top = rag.retrieve(message, agent["id"], top_k=5)
|
||||
if not top:
|
||||
return {
|
||||
"answer": "I couldn't find relevant Washington law on that in my reference "
|
||||
"library yet. Try rephrasing, or ask the Navigator to point you to "
|
||||
"the right specialist.",
|
||||
"citations": [],
|
||||
"grounded": False,
|
||||
}
|
||||
|
||||
context_blocks = []
|
||||
citations = []
|
||||
seen = set()
|
||||
for i, (score, c) in enumerate(top, 1):
|
||||
key = (c["source"], c["title"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
context_blocks.append(f"[{i}] ({c['source']} — {c['title']})\n{c['text']}")
|
||||
citations.append({
|
||||
"source": c["source"],
|
||||
"title": c["title"],
|
||||
"score": round(score, 3),
|
||||
"snippet": c["text"][:260] + ("…" if len(c["text"]) > 260 else ""),
|
||||
})
|
||||
|
||||
system = (
|
||||
f"{agent['system']}\n\n"
|
||||
"Rules:\n"
|
||||
"- Answer the user's question DIRECTLY and concisely. Begin your answer immediately — "
|
||||
"do NOT restate the question, do NOT narrate your reasoning, and do NOT say what you "
|
||||
"are about to do.\n"
|
||||
"- Answer using ONLY the reference documents provided below.\n"
|
||||
"- Cite the RCW section (or source) inline for every legal claim, e.g. (RCW 26.09.030).\n"
|
||||
"- If the documents do not contain the answer, say so clearly and suggest which "
|
||||
"specialist or official resource to consult.\n"
|
||||
"- Be practical and plain-English, specific to Washington State.\n"
|
||||
"- Do NOT write a 'Sources' list at the end; cite inline only."
|
||||
)
|
||||
user = (
|
||||
"REFERENCE DOCUMENTS:\n\n"
|
||||
+ "\n\n".join(context_blocks)
|
||||
+ f"\n\nUSER QUESTION: {message}"
|
||||
)
|
||||
|
||||
messages = [{"role": "system", "content": system}]
|
||||
if history:
|
||||
for turn in history[-6:]:
|
||||
if turn.get("role") in ("user", "assistant"):
|
||||
messages.append({"role": turn["role"], "content": turn["content"]})
|
||||
messages.append({"role": "user", "content": user})
|
||||
|
||||
try:
|
||||
answer = _ollama_chat(RAG_MODEL, messages)
|
||||
except Exception as e:
|
||||
# Fall back to the general model if the RAG lane is unavailable.
|
||||
try:
|
||||
answer = _ollama_chat(GENERAL_MODEL, messages, num_ctx=16384)
|
||||
except Exception:
|
||||
return {
|
||||
"answer": "The legal engine is temporarily unavailable. Please try again in a "
|
||||
"moment. (Backend LLM could not be reached.)",
|
||||
"citations": [],
|
||||
"grounded": False,
|
||||
}
|
||||
|
||||
answer = _strip_model_sources(answer)
|
||||
return {
|
||||
"answer": answer,
|
||||
"citations": citations,
|
||||
"grounded": True,
|
||||
"model": RAG_MODEL,
|
||||
}
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
agents_public = [
|
||||
{k: a[k] for k in ("id", "name", "emoji", "tagline", "description", "accent")}
|
||||
for a in AGENTS
|
||||
]
|
||||
return render_template("index.html", agents=agents_public, disclaimer=DISCLAIMER)
|
||||
|
||||
|
||||
@app.route("/api/agents")
|
||||
def api_agents():
|
||||
return jsonify([
|
||||
{k: a[k] for k in ("id", "name", "emoji", "tagline", "description", "accent")}
|
||||
for a in AGENTS
|
||||
])
|
||||
|
||||
|
||||
@app.route("/api/chat", methods=["POST"])
|
||||
def api_chat():
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
agent_id = (data.get("agent_id") or "navigator").strip()
|
||||
message = (data.get("message") or "").strip()
|
||||
history = data.get("history") or []
|
||||
agent = AGENT_BY_ID.get(agent_id)
|
||||
if not agent:
|
||||
return jsonify({"error": "Unknown agent"}), 400
|
||||
if not message:
|
||||
return jsonify({"error": "Message required"}), 400
|
||||
if len(message) > 4000:
|
||||
message = message[:4000]
|
||||
|
||||
t0 = time.time()
|
||||
result = _build_answer(agent, message, history)
|
||||
result["latency_ms"] = round((time.time() - t0) * 1000)
|
||||
result["agent"] = agent_id
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/api/health")
|
||||
def health():
|
||||
return jsonify({"ok": True, "agent_count": len(AGENTS), "index": rag.index_status()})
|
||||
|
||||
|
||||
@app.route("/api/index/status")
|
||||
def index_status():
|
||||
return jsonify(rag.index_status())
|
||||
|
||||
|
||||
@app.route("/api/reindex", methods=["POST"])
|
||||
def reindex():
|
||||
try:
|
||||
idx = rag.build_index()
|
||||
return jsonify({"ok": True, "chunks": len(idx)})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
# Kick off the background index build once at import (before serving).
|
||||
rag.start_background_index()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, threaded=True)
|
||||
14
astraea-nginx.conf
Normal file
14
astraea-nginx.conf
Normal file
@@ -0,0 +1,14 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300;
|
||||
proxy_send_timeout 300;
|
||||
}
|
||||
}
|
||||
13
astraea.service
Normal file
13
astraea.service
Normal file
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Astraea — Washington family-law specialist agents (Flask + RAG)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/opt/astraea
|
||||
ExecStart=/opt/astraea/venv/bin/gunicorn --workers 2 --threads 4 --bind 0.0.0.0:5000 --timeout 240 app:app
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
185
rag.py
Normal file
185
rag.py
Normal file
@@ -0,0 +1,185 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Semantic RAG engine for Astraea — chunk the law 'books', embed, and retrieve.
|
||||
|
||||
stdlib-only (urllib + json + math), no numpy needed. Embeddings and generation
|
||||
run on the Ollama host (nightmare). The vector index is cached to disk and rebuilt
|
||||
incrementally only when a book changes.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import urllib.request
|
||||
|
||||
from agents import OLLAMA_URL, EMBED_MODEL, BOOKS_DIR, AGENTS
|
||||
|
||||
INDEX_PATH = "/opt/astraea/index.json"
|
||||
|
||||
|
||||
def _post(url, payload, timeout=120):
|
||||
"""POST JSON to Ollama with a no-proxy opener (safe on LAN)."""
|
||||
req = urllib.request.Request(
|
||||
url, data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
with opener.open(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
|
||||
|
||||
def embed(text):
|
||||
"""Return a float vector for `text` via the Ollama embeddings endpoint."""
|
||||
d = _post(f"{OLLAMA_URL}/api/embeddings", {"model": EMBED_MODEL, "prompt": text})
|
||||
v = d.get("embedding") or d.get("embeddings")
|
||||
if isinstance(v, list) and v and isinstance(v[0], list):
|
||||
v = v[0]
|
||||
if not v:
|
||||
raise RuntimeError("empty embedding returned")
|
||||
return v
|
||||
|
||||
|
||||
def _cosine(a, b):
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = math.sqrt(sum(x * x for x in a))
|
||||
nb = math.sqrt(sum(y * y for y in b))
|
||||
return dot / (na * nb) if na and nb else 0.0
|
||||
|
||||
|
||||
MAX_CHUNK = 1100 # chars — stay safely under the embedding model's context budget
|
||||
|
||||
|
||||
def _split_long(text, limit):
|
||||
"""Split text into pieces <= limit chars, preferring sentence boundaries."""
|
||||
if len(text) <= limit:
|
||||
return [text] if text.strip() else []
|
||||
parts = re.split(r"(?<=[.!?])\s+", text)
|
||||
out = []
|
||||
cur = ""
|
||||
for p in parts:
|
||||
if cur and len(cur) + len(p) + 1 > limit:
|
||||
out.append(cur)
|
||||
cur = p
|
||||
else:
|
||||
cur = (cur + " " + p).strip() if cur else p
|
||||
while len(cur) > limit:
|
||||
out.append(cur[:limit])
|
||||
cur = cur[limit:]
|
||||
if cur.strip():
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
|
||||
def _chunk_markdown(text):
|
||||
"""Split markdown into self-contained (title, body) chunks, each <= MAX_CHUNK chars."""
|
||||
lines = text.splitlines()
|
||||
chunks = []
|
||||
cur_title = None
|
||||
cur_buf = []
|
||||
|
||||
def flush():
|
||||
nonlocal cur_title, cur_buf
|
||||
if not cur_buf:
|
||||
return
|
||||
body = "\n".join(cur_buf).strip()
|
||||
cur_buf = []
|
||||
if not body:
|
||||
return
|
||||
for piece in _split_long(body, MAX_CHUNK):
|
||||
chunks.append((cur_title or "Section", piece))
|
||||
|
||||
for line in lines:
|
||||
if re.match(r"^#{1,4}\s+", line):
|
||||
flush()
|
||||
cur_title = re.sub(r"^#{1,4}\s+", "", line).strip()
|
||||
else:
|
||||
cur_buf.append(line)
|
||||
flush()
|
||||
return chunks
|
||||
|
||||
|
||||
def build_index():
|
||||
"""Build (or load from cache) the vector index. Returns list of chunk dicts."""
|
||||
index = []
|
||||
book_files = []
|
||||
for a in AGENTS:
|
||||
for b in a["books"]:
|
||||
path = os.path.join(BOOKS_DIR, b)
|
||||
if os.path.exists(path):
|
||||
book_files.append((path, a["id"]))
|
||||
|
||||
# Decide whether to reuse cache: index.json exists, embed model matches, and no book changed.
|
||||
if os.path.exists(INDEX_PATH):
|
||||
try:
|
||||
with open(INDEX_PATH) as f:
|
||||
cached = json.load(f)
|
||||
if cached.get("embed_model") == EMBED_MODEL:
|
||||
newest_book = max(os.path.getmtime(p) for p, _ in book_files) if book_files else 0
|
||||
if os.path.getmtime(INDEX_PATH) >= newest_book:
|
||||
return cached["chunks"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for path, agent_id in book_files:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
except Exception:
|
||||
continue
|
||||
base = os.path.basename(path)
|
||||
for title, body in _chunk_markdown(text):
|
||||
# Embed the title + body together for best semantic match.
|
||||
try:
|
||||
vec = embed(f"{title}\n{body}")
|
||||
except Exception:
|
||||
continue # skip any chunk that fails to embed
|
||||
index.append({
|
||||
"agent": agent_id,
|
||||
"source": base,
|
||||
"title": title,
|
||||
"text": body,
|
||||
"vector": vec,
|
||||
})
|
||||
|
||||
if index:
|
||||
try:
|
||||
with open(INDEX_PATH, "w") as f:
|
||||
json.dump({"embed_model": EMBED_MODEL, "chunks": index}, f)
|
||||
except Exception:
|
||||
pass
|
||||
return index
|
||||
|
||||
|
||||
def retrieve(query, agent_id, top_k=5):
|
||||
"""Return top-k relevant chunks for `agent_id` via cosine similarity."""
|
||||
qvec = embed(query)
|
||||
scored = []
|
||||
for c in _INDEX:
|
||||
if c["agent"] != agent_id:
|
||||
continue
|
||||
scored.append((_cosine(qvec, c["vector"]), c))
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return [(s, c) for s, c in scored[:top_k]]
|
||||
|
||||
|
||||
# Global index, built lazily once in a background thread.
|
||||
_INDEX = []
|
||||
_index_lock = threading.Lock()
|
||||
_index_ready = False
|
||||
|
||||
|
||||
def start_background_index():
|
||||
def _run():
|
||||
global _index_ready
|
||||
try:
|
||||
idx = build_index()
|
||||
with _index_lock:
|
||||
_INDEX.clear()
|
||||
_INDEX.extend(idx)
|
||||
finally:
|
||||
_index_ready = True
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
|
||||
def index_status():
|
||||
return {"ready": _index_ready, "chunks": len(_INDEX)}
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask>=3.0
|
||||
gunicorn>=21.2
|
||||
408
templates/index.html
Normal file
408
templates/index.html
Normal file
@@ -0,0 +1,408 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Astraea — Washington Family Law, Decoded</title>
|
||||
<meta name="description" content="A constellation of Washington State family-law specialist agents. Ask about divorce, custody, child support, property, and protection orders.">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #070b1a;
|
||||
--bg2: #0b1228;
|
||||
--panel: #101832;
|
||||
--panel2: #141d3d;
|
||||
--line: #233055;
|
||||
--text: #e8edfb;
|
||||
--muted: #8b98c4;
|
||||
--accent: #7dd3fc;
|
||||
--gold: #f5d06f;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: radial-gradient(1200px 700px at 50% -10%, #18254f 0%, var(--bg2) 45%, var(--bg) 100%);
|
||||
color: var(--text);
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
#stars { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
|
||||
|
||||
.wrap { position: relative; z-index: 1; max-width: 1120px; margin: 0 auto; padding: 0 22px 80px; }
|
||||
|
||||
header {
|
||||
padding: 46px 0 8px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 14px; }
|
||||
.logo {
|
||||
width: 46px; height: 46px; border-radius: 13px;
|
||||
background: linear-gradient(135deg, #6ea8ff, #a78bfa);
|
||||
display: grid; place-items: center; font-size: 26px;
|
||||
box-shadow: 0 0 26px rgba(124, 155, 255, .55);
|
||||
}
|
||||
.brand h1 { font-size: 26px; font-weight: 700; letter-spacing: .4px; }
|
||||
.brand h1 span { color: var(--gold); }
|
||||
.brand .sub { color: var(--muted); font-size: 13px; margin-top: 2px; letter-spacing: .3px; }
|
||||
.pill {
|
||||
font-size: 12px; color: var(--muted); border: 1px solid var(--line);
|
||||
padding: 6px 12px; border-radius: 999px; display: inline-flex; align-items: center; gap: 7px;
|
||||
}
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 10px #4ade80; }
|
||||
|
||||
.hero { padding: 26px 0 34px; max-width: 720px; }
|
||||
.hero h2 { font-size: clamp(28px, 5vw, 46px); line-height: 1.12; font-weight: 800; letter-spacing: -.5px; }
|
||||
.hero h2 .grad {
|
||||
background: linear-gradient(90deg, #7dd3fc, #a78bfa, #f0abfc);
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
}
|
||||
.hero p { color: var(--muted); font-size: 16.5px; line-height: 1.6; margin-top: 14px; max-width: 640px; }
|
||||
|
||||
.section-label { font-size: 12px; text-transform: uppercase; letter-spacing: 2px; color: var(--muted); margin: 10px 0 16px; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
|
||||
.card {
|
||||
background: linear-gradient(180deg, var(--panel2), var(--panel));
|
||||
border: 1px solid var(--line); border-radius: 16px; padding: 18px 18px 16px;
|
||||
cursor: pointer; transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.card::before {
|
||||
content: ""; position: absolute; inset: 0 0 auto 0; height: 3px;
|
||||
background: var(--c, var(--accent)); opacity: .85;
|
||||
}
|
||||
.card:hover { transform: translateY(-3px); border-color: var(--c, var(--accent)); box-shadow: 0 14px 34px rgba(0,0,0,.4); }
|
||||
.card.active { border-color: var(--c, var(--accent)); box-shadow: 0 0 0 1px var(--c, var(--accent)), 0 16px 40px rgba(0,0,0,.5); }
|
||||
.card .emoji { font-size: 30px; line-height: 1; }
|
||||
.card h3 { font-size: 16.5px; font-weight: 700; margin: 12px 0 3px; }
|
||||
.card .tag { font-size: 12px; color: var(--c, var(--accent)); font-weight: 600; margin-bottom: 8px; }
|
||||
.card p { font-size: 13px; color: var(--muted); line-height: 1.5; }
|
||||
|
||||
.chat-panel {
|
||||
margin-top: 38px;
|
||||
background: linear-gradient(180deg, var(--panel2), var(--panel));
|
||||
border: 1px solid var(--line); border-radius: 20px;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
box-shadow: 0 30px 70px rgba(0,0,0,.45);
|
||||
}
|
||||
.chat-head {
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--line);
|
||||
display: flex; align-items: center; gap: 13px;
|
||||
}
|
||||
.chat-head .emoji { font-size: 26px; }
|
||||
.chat-head .who { font-weight: 700; font-size: 15.5px; }
|
||||
.chat-head .who .scope { color: var(--muted); font-weight: 500; font-size: 12.5px; margin-top: 1px; }
|
||||
.chips { margin-left: auto; display: flex; gap: 7px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.chip {
|
||||
font-size: 12px; padding: 6px 11px; border-radius: 999px; cursor: pointer;
|
||||
border: 1px solid var(--line); color: var(--muted); background: transparent;
|
||||
transition: all .15s ease; white-space: nowrap;
|
||||
}
|
||||
.chip:hover { color: var(--text); border-color: var(--muted); }
|
||||
.chip.on { color: #06121f; background: var(--c, var(--accent)); border-color: var(--c, var(--accent)); font-weight: 700; }
|
||||
|
||||
.messages { padding: 22px; min-height: 240px; max-height: 520px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
.msg { max-width: 78%; display: flex; flex-direction: column; }
|
||||
.msg.user { align-self: flex-end; align-items: flex-end; }
|
||||
.msg.agent { align-self: flex-start; align-items: flex-start; }
|
||||
.bubble {
|
||||
padding: 13px 16px; border-radius: 16px; font-size: 15px; line-height: 1.6; white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.msg.user .bubble { background: linear-gradient(135deg, #3b82f6, #6366f1); color: #fff; border-bottom-right-radius: 4px; }
|
||||
.msg.agent .bubble { background: var(--panel2); border: 1px solid var(--line); border-bottom-left-radius: 4px; }
|
||||
.msg .who-label { font-size: 11.5px; color: var(--muted); margin: 0 4px 5px; }
|
||||
|
||||
.citations { margin-top: 10px; width: 100%; }
|
||||
.cite-toggle {
|
||||
font-size: 12px; color: var(--gold); cursor: pointer; border: none; background: none;
|
||||
display: inline-flex; align-items: center; gap: 5px; padding: 4px 2px;
|
||||
}
|
||||
.cite-list { margin-top: 8px; display: none; flex-direction: column; gap: 8px; }
|
||||
.cite-list.open { display: flex; }
|
||||
.cite {
|
||||
background: #0c142e; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px;
|
||||
font-size: 12.5px; color: var(--muted);
|
||||
}
|
||||
.cite b { color: var(--text); display: block; margin-bottom: 3px; font-size: 13px; }
|
||||
.cite .src { color: var(--gold); font-size: 11px; }
|
||||
|
||||
.typing { display: inline-flex; gap: 5px; align-items: center; padding: 6px 2px; }
|
||||
.typing i { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); animation: blink 1.2s infinite; }
|
||||
.typing i:nth-child(2) { animation-delay: .2s; }
|
||||
.typing i:nth-child(3) { animation-delay: .4s; }
|
||||
@keyframes blink { 0%,100% { opacity: .25; } 50% { opacity: 1; } }
|
||||
|
||||
.composer { border-top: 1px solid var(--line); padding: 14px 16px; display: flex; gap: 10px; align-items: flex-end; }
|
||||
.composer textarea {
|
||||
flex: 1; resize: none; background: #0c142e; border: 1px solid var(--line); color: var(--text);
|
||||
border-radius: 12px; padding: 12px 14px; font-size: 15px; font-family: inherit; line-height: 1.5;
|
||||
max-height: 140px; outline: none; transition: border-color .15s ease;
|
||||
}
|
||||
.composer textarea:focus { border-color: var(--c, var(--accent)); }
|
||||
.send {
|
||||
border: none; border-radius: 12px; padding: 13px 20px; font-size: 15px; font-weight: 700;
|
||||
color: #06121f; background: var(--c, var(--accent)); cursor: pointer; transition: transform .1s ease, opacity .2s;
|
||||
}
|
||||
.send:active { transform: scale(.96); }
|
||||
.send:disabled { opacity: .45; cursor: default; }
|
||||
.hint { font-size: 11.5px; color: var(--muted); text-align: center; padding: 0 22px 14px; }
|
||||
|
||||
.disclaimer {
|
||||
margin-top: 26px; font-size: 12.5px; color: var(--muted); text-align: center; line-height: 1.6;
|
||||
border-top: 1px solid var(--line); padding-top: 20px;
|
||||
}
|
||||
footer { margin-top: 20px; text-align: center; }
|
||||
.bmac {
|
||||
display: inline-flex; align-items: center; gap: 9px; color: var(--text); text-decoration: none;
|
||||
background: var(--panel2); border: 1px solid var(--line); padding: 10px 18px; border-radius: 999px;
|
||||
font-size: 13.5px; font-weight: 600; transition: all .15s ease;
|
||||
}
|
||||
.bmac:hover { border-color: var(--gold); box-shadow: 0 0 18px rgba(245,208,111,.25); }
|
||||
.bmac .cup { font-size: 17px; }
|
||||
|
||||
#mascot {
|
||||
position: fixed; z-index: 2; pointer-events: none; will-change: transform;
|
||||
font-size: 24px; filter: drop-shadow(0 0 8px rgba(255,220,150,.8)); opacity: .9;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.chips { margin-left: 0; width: 100%; justify-content: flex-start; }
|
||||
.chat-head { flex-wrap: wrap; }
|
||||
.msg { max-width: 92%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="stars"></canvas>
|
||||
<div id="mascot">✦</div>
|
||||
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="brand">
|
||||
<div class="logo">⚖️</div>
|
||||
<div>
|
||||
<h1>ASTRA<span>EA</span></h1>
|
||||
<div class="sub">Washington Family Law, decoded by specialist agents</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pill"><span class="dot"></span> <span id="statusText">Engines online</span></div>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<h2>Eight specialists.<br>One <span class="grad">constellation</span> of Washington family law.</h2>
|
||||
<p>Ask a divorce, custody, child-support, property, or protection-order question. Each specialist answers from the actual Revised Code of Washington — with citations — not from thin air.</p>
|
||||
</section>
|
||||
|
||||
<div class="section-label">Choose your specialist</div>
|
||||
<div class="grid" id="grid"></div>
|
||||
|
||||
<section class="chat-panel" id="chatPanel">
|
||||
<div class="chat-head">
|
||||
<div class="emoji" id="headEmoji">🧭</div>
|
||||
<div>
|
||||
<div class="who" id="headName">Navigator</div>
|
||||
<div class="who"><div class="scope" id="headScope">Where to start</div></div>
|
||||
</div>
|
||||
<div class="chips" id="chips"></div>
|
||||
</div>
|
||||
<div class="messages" id="messages"></div>
|
||||
<div class="composer">
|
||||
<textarea id="input" rows="1" placeholder="Ask a Washington family-law question…"></textarea>
|
||||
<button class="send" id="sendBtn">Ask</button>
|
||||
</div>
|
||||
<div class="hint">Answers cite the Revised Code of Washington. This is legal information, not advice.</div>
|
||||
</section>
|
||||
|
||||
<div class="disclaimer">{{ disclaimer }}</div>
|
||||
<footer>
|
||||
<a class="bmac" href="https://buymeacoffee.com/r26xrthzttg" target="_blank" rel="noopener">
|
||||
<span class="cup">☕</span> Support Astraea
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const AGENTS = {{ agents | tojson }};
|
||||
const DISCLAIMER = {{ disclaimer | tojson }};
|
||||
|
||||
// ---- Starfield + cursor mascot ----
|
||||
const cvs = document.getElementById('stars');
|
||||
const ctx = cvs.getContext('2d');
|
||||
let stars = [];
|
||||
function sizeCanvas() {
|
||||
cvs.width = window.innerWidth; cvs.height = window.innerHeight;
|
||||
stars = Array.from({length: 130}, () => ({
|
||||
x: Math.random() * cvs.width, y: Math.random() * cvs.height,
|
||||
r: Math.random() * 1.4 + .3, tw: Math.random() * Math.PI * 2,
|
||||
sp: Math.random() * .02 + .005
|
||||
}));
|
||||
}
|
||||
sizeCanvas();
|
||||
window.addEventListener('resize', sizeCanvas);
|
||||
function drawStars() {
|
||||
ctx.clearRect(0, 0, cvs.width, cvs.height);
|
||||
for (const s of stars) {
|
||||
s.tw += s.sp;
|
||||
const a = .35 + Math.abs(Math.sin(s.tw)) * .65;
|
||||
ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(190,210,255,${a})`; ctx.fill();
|
||||
}
|
||||
requestAnimationFrame(drawStars);
|
||||
}
|
||||
drawStars();
|
||||
|
||||
const mascot = document.getElementById('mascot');
|
||||
let mx = innerWidth/2, my = innerHeight/3, cx = mx, cy = my;
|
||||
addEventListener('mousemove', e => { mx = e.clientX; my = e.clientY; });
|
||||
(function follow() {
|
||||
cx += (mx - cx) * .08; cy += (my - cy) * .08;
|
||||
mascot.style.transform = `translate(${cx - 8}px, ${cy - 30}px) rotate(${Math.sin(Date.now()/700)*20}deg)`;
|
||||
requestAnimationFrame(follow);
|
||||
})();
|
||||
|
||||
// ---- App state ----
|
||||
const grid = document.getElementById('grid');
|
||||
const chips = document.getElementById('chips');
|
||||
const messages = document.getElementById('messages');
|
||||
const input = document.getElementById('input');
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
const statusText = document.getElementById('statusText');
|
||||
let active = AGENTS[0].id;
|
||||
const history = {}; // per-agent turn history
|
||||
AGENTS.forEach(a => history[a.id] = []);
|
||||
|
||||
function accent(id) { const a = AGENTS.find(x => x.id === id); return a ? a.accent : '#7dd3fc'; }
|
||||
|
||||
function renderGrid() {
|
||||
grid.innerHTML = '';
|
||||
AGENTS.forEach(a => {
|
||||
const c = document.createElement('div');
|
||||
c.className = 'card' + (a.id === active ? ' active' : '');
|
||||
c.style.setProperty('--c', a.accent);
|
||||
c.innerHTML = `<div class="emoji">${a.emoji}</div><h3>${a.name}</h3>
|
||||
<div class="tag">${a.tagline}</div><p>${a.description}</p>`;
|
||||
c.onclick = () => selectAgent(a.id, true);
|
||||
grid.appendChild(c);
|
||||
});
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
chips.innerHTML = '';
|
||||
AGENTS.forEach(a => {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'chip' + (a.id === active ? ' on' : '');
|
||||
b.style.setProperty('--c', a.accent);
|
||||
b.textContent = a.emoji + ' ' + a.name.split(' & ')[0].split(' ')[0];
|
||||
b.onclick = () => selectAgent(a.id, false);
|
||||
chips.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
function selectAgent(id, scroll) {
|
||||
active = id;
|
||||
const a = AGENTS.find(x => x.id === id);
|
||||
document.getElementById('headEmoji').textContent = a.emoji;
|
||||
document.getElementById('headName').textContent = a.name;
|
||||
document.getElementById('headScope').textContent = a.tagline;
|
||||
document.getElementById('chatPanel').style.setProperty('--c', a.accent);
|
||||
document.getElementById('chatPanel').style.borderColor = a.accent + '55';
|
||||
renderGrid(); renderChips();
|
||||
messages.innerHTML = '';
|
||||
renderHistory();
|
||||
if (scroll) document.getElementById('chatPanel').scrollIntoView({behavior: 'smooth', block: 'start'});
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
const h = history[active] || [];
|
||||
h.forEach(t => addBubble(t.role, t.content, t.citations));
|
||||
}
|
||||
|
||||
function addBubble(role, text, citations) {
|
||||
const m = document.createElement('div');
|
||||
m.className = 'msg ' + role;
|
||||
const label = document.createElement('div');
|
||||
label.className = 'who-label';
|
||||
label.textContent = role === 'user' ? 'You' : (AGENTS.find(x => x.id === active)?.name || 'Agent');
|
||||
const b = document.createElement('div');
|
||||
b.className = 'bubble';
|
||||
b.textContent = text;
|
||||
m.appendChild(label); m.appendChild(b);
|
||||
if (role === 'assistant' && citations && citations.length) {
|
||||
const cw = document.createElement('div'); cw.className = 'citations';
|
||||
const t = document.createElement('button'); t.className = 'cite-toggle';
|
||||
t.innerHTML = '🔗 ' + citations.length + ' source' + (citations.length > 1 ? 's' : '');
|
||||
const list = document.createElement('div'); list.className = 'cite-list';
|
||||
citations.forEach(c => {
|
||||
const item = document.createElement('div'); item.className = 'cite';
|
||||
item.innerHTML = `<b>${c.title}</b><div>${c.snippet}</div><div class="src">${c.source}</div>`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
t.onclick = () => list.classList.toggle('open');
|
||||
cw.appendChild(t); cw.appendChild(list);
|
||||
m.appendChild(cw);
|
||||
}
|
||||
messages.appendChild(m);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function typingIndicator(on) {
|
||||
document.querySelectorAll('.typing').forEach(e => e.remove());
|
||||
if (!on) return;
|
||||
const m = document.createElement('div'); m.className = 'msg agent';
|
||||
const t = document.createElement('div'); t.className = 'typing';
|
||||
t.innerHTML = '<i></i><i></i><i></i>';
|
||||
m.appendChild(t); messages.appendChild(m);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = ''; input.style.height = 'auto';
|
||||
addBubble('user', text);
|
||||
history[active].push({role: 'user', content: text});
|
||||
typingIndicator(true); sendBtn.disabled = true;
|
||||
try {
|
||||
const r = await fetch('/api/chat', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({agent_id: active, message: text, history: history[active].slice(0, -1)})
|
||||
});
|
||||
const d = await r.json();
|
||||
typingIndicator(false);
|
||||
if (d.answer) {
|
||||
addBubble('assistant', d.answer, d.citations);
|
||||
history[active].push({role: 'assistant', content: d.answer, citations: d.citations});
|
||||
} else {
|
||||
addBubble('assistant', d.error || 'Something went wrong. Please try again.', null);
|
||||
}
|
||||
} catch (e) {
|
||||
typingIndicator(false);
|
||||
addBubble('assistant', 'Could not reach the legal engine. Please try again.', null);
|
||||
} finally {
|
||||
sendBtn.disabled = false; input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
sendBtn.onclick = send;
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
||||
});
|
||||
input.addEventListener('input', () => {
|
||||
input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 140) + 'px';
|
||||
});
|
||||
|
||||
// ---- Boot ----
|
||||
renderGrid(); renderChips(); selectAgent(active, false);
|
||||
fetch('/api/health').then(r => r.json()).then(d => {
|
||||
if (d.ok) {
|
||||
statusText.textContent = d.index.ready
|
||||
? `${d.agent_count} agents · ${d.index.chunks} law passages indexed`
|
||||
: 'Indexing law library…';
|
||||
}
|
||||
}).catch(() => { statusText.textContent = 'Engine warming up'; });
|
||||
</script>
|
||||
|
||||
<!-- Umami analytics beacon -->
|
||||
<script async src="https://analytics.thetempleofdoom.com/script.js" data-website-id="c8c2f69c-4448-40ab-9848-02179f10c001"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user