Astraea v2: auth+profiles+About-Me, documents, TTS, Twilio/SMTP missions, DV safety, conversation memory, FLUX landing

This commit is contained in:
drjones
2026-09-05 08:04:35 -07:00
parent 913edc0951
commit b075699cb6
11 changed files with 1570 additions and 473 deletions

View File

@@ -6,8 +6,8 @@ Each agent scopes a domain and pulls from its own set of 'books' (markdown sourc
# Backend Ollama (nightmare, RTX 4080 SUPER). Renumbers after reboot — .29 as of Sept 2026. # Backend Ollama (nightmare, RTX 4080 SUPER). Renumbers after reboot — .29 as of Sept 2026.
OLLAMA_URL = "http://10.30.20.29:11434" OLLAMA_URL = "http://10.30.20.29:11434"
EMBED_MODEL = "nomic-embed-text-v2-moe:latest" # semantic embeddings (verified on nightmare) 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) RAG_MODEL = "ornith-1.5:9b" # verified best legal-advice answer lane (benchmark Sept 2026)
GENERAL_MODEL = "qwen3.8fast:latest" # fallback / general reasoning GENERAL_MODEL = "granite4.2:latest" # fallback (reliable, grounded)
BOOKS_DIR = "/opt/astraea/books" BOOKS_DIR = "/opt/astraea/books"
@@ -19,7 +19,7 @@ AGENTS = [
"emoji": "\U0001F9ED", "emoji": "\U0001F9ED",
"tagline": "Where to start", "tagline": "Where to start",
"description": "Point you at the right specialist and walk through the Washington family-law process, from residency to final decree.", "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"], "books": ["overview-intake.md", "mediation-adr.md", "adoption.md"],
"accent": "#7dd3fc", "accent": "#7dd3fc",
"system": ( "system": (
"You are the Navigator, the intake specialist for Astraea, a Washington State " "You are the Navigator, the intake specialist for Astraea, a Washington State "
@@ -36,7 +36,8 @@ AGENTS = [
"emoji": "\u2696\uFE0F", "emoji": "\u2696\uFE0F",
"tagline": "Ending a marriage", "tagline": "Ending a marriage",
"description": "No-fault dissolution, legal separation, the 90-day wait, grounds, residency, and the decree.", "description": "No-fault dissolution, legal separation, the 90-day wait, grounds, residency, and the decree.",
"books": ["divorce-dissolution.md"], "books": ["divorce-dissolution.md", "divorce-case-law-characterization.md",
"divorce-case-law-division.md", "divorce-case-law-maintenance.md"],
"accent": "#a5b4fc", "accent": "#a5b4fc",
"system": ( "system": (
"You are the Divorce & Dissolution specialist for Astraea, a Washington State " "You are the Divorce & Dissolution specialist for Astraea, a Washington State "
@@ -44,6 +45,8 @@ AGENTS = [
"marriage and legal separation under RCW 26.09: residency requirements, the petition, " "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. " "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. " "Be clear about what 'no-fault' means and what the court can and cannot decide. "
"Where relevant, cite the controlling Washington case law (e.g. 'In re Marriage of "
"Littlefield') alongside the statute — not just the RCW."
), ),
}, },
{ {
@@ -52,15 +55,16 @@ AGENTS = [
"emoji": "\U0001F9D2", "emoji": "\U0001F9D2",
"tagline": "Parenting plans", "tagline": "Parenting plans",
"description": "Residential schedules, best-interest factors, restrictions, relocation, and nonparental custody.", "description": "Residential schedules, best-interest factors, restrictions, relocation, and nonparental custody.",
"books": ["custody-parenting.md"], "books": ["custody-parenting.md", "parentage.md", "guardianship.md"],
"accent": "#86efac", "accent": "#86efac",
"system": ( "system": (
"You are the Child Custody & Parenting Plans specialist for Astraea, a Washington " "You are the Child Custody & Parenting Plans specialist for Astraea, a Washington "
"State family-law assistant. Explain parenting plans and residential schedules under " "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.184-.191: the best-interest-of-the-child factors, restrictions under "
"RCW 26.09.191 (domestic violence, abuse, etc.), decision-making, relocation, " "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 nonparental custody. Note: nonparental custody is now minor guardianship under "
"and best interests." "chapter 11.130 RCW and relatives' visitation under chapter 26.11 RCW (the old "
"RCW 26.10 was repealed in 2020). Always center the child's safety and best interests."
), ),
}, },
{ {
@@ -69,7 +73,7 @@ AGENTS = [
"emoji": "\U0001F4B5", "emoji": "\U0001F4B5",
"tagline": "The support schedule", "tagline": "The support schedule",
"description": "Income-shares calculation, the WA economic table, deviations, imputed income, and modification.", "description": "Income-shares calculation, the WA economic table, deviations, imputed income, and modification.",
"books": ["child-support.md"], "books": ["child-support.md", "modification-enforcement.md"],
"accent": "#fcd34d", "accent": "#fcd34d",
"system": ( "system": (
"You are the Child Support specialist for Astraea, a Washington State family-law " "You are the Child Support specialist for Astraea, a Washington State family-law "
@@ -85,7 +89,7 @@ AGENTS = [
"emoji": "\U0001F54A\uFE0F", "emoji": "\U0001F54A\uFE0F",
"tagline": "Alimony in WA", "tagline": "Alimony in WA",
"description": "Maintenance factors, duration, and when spousal support is ordered or modified.", "description": "Maintenance factors, duration, and when spousal support is ordered or modified.",
"books": ["spousal-maintenance.md"], "books": ["spousal-maintenance.md", "modification-enforcement.md", "divorce-case-law-maintenance.md"],
"accent": "#f0abfc", "accent": "#f0abfc",
"system": ( "system": (
"You are the Spousal Maintenance (alimony) specialist for Astraea, a Washington State " "You are the Spousal Maintenance (alimony) specialist for Astraea, a Washington State "
@@ -101,7 +105,7 @@ AGENTS = [
"emoji": "\U0001F3E0", "emoji": "\U0001F3E0",
"tagline": "Community property", "tagline": "Community property",
"description": "Separate vs. community property, characterization, division, pensions, and marital debt.", "description": "Separate vs. community property, characterization, division, pensions, and marital debt.",
"books": ["property-debt.md"], "books": ["property-debt.md", "committed-relationships.md"],
"accent": "#fdba74", "accent": "#fdba74",
"system": ( "system": (
"You are the Property & Debt Division specialist for Astraea, a Washington State " "You are the Property & Debt Division specialist for Astraea, a Washington State "
@@ -147,4 +151,4 @@ AGENTS = [
AGENT_BY_ID = {a["id"]: a for a in AGENTS} 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." DISCLAIMER = "Astraea gives practical legal guidance grounded in Washington statutes and case law. It is not a substitute for a licensed Washington attorney, who can advise on the specifics of your case."

474
app.py
View File

@@ -1,67 +1,142 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Astraea — a constellation of Washington State family-law specialist agents. """Astraea — the world's best Washington divorce-law attorney.
Multi-agent RAG + no-KYC profiles (About Me) + document prep + comms missions + TTS.
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 json
import re import re
import time import time
import urllib.request import urllib.request
from flask import Flask, jsonify, render_template, request from flask import Flask, jsonify, render_template, request, send_file, Response
import rag import rag
from agents import ( import store
AGENTS, AGENT_BY_ID, OLLAMA_URL, RAG_MODEL, GENERAL_MODEL, DISCLAIMER, import comms
) import documents
import tts
from agents import AGENTS, AGENT_BY_ID, OLLAMA_URL, RAG_MODEL, GENERAL_MODEL
app = Flask(__name__) app = Flask(__name__)
# ── Professional avatars (gradient + glyph, rendered client-side) ──
def _ollama_chat(model, messages, num_ctx=16384, num_predict=1024, temperature=0.1, timeout=180): AVATARS = [
payload = { {"id": 0, "glyph": "\u2696", "c1": "#6ea8ff", "c2": "#a78bfa", "label": "Scales"},
"model": model, {"id": 1, "glyph": "\U0001F3DB", "c1": "#7dd3fc", "c2": "#0ea5e9", "label": "Court"},
"messages": messages, {"id": 2, "glyph": "\U0001F6E1", "c1": "#4ade80", "c2": "#059669", "label": "Shield"},
"stream": False, {"id": 3, "glyph": "\U0001F4DC", "c1": "#f5d06f", "c2": "#d97706", "label": "Scroll"},
"think": False, {"id": 4, "glyph": "\U0001F54A", "c1": "#f0abfc", "c2": "#c026d3", "label": "Dove"},
"options": { {"id": 5, "glyph": "\U0001F4BC", "c1": "#fdba74", "c2": "#ea580c", "label": "Briefcase"},
"temperature": temperature, {"id": 6, "glyph": "\U0001F512", "c1": "#67e8f9", "c2": "#0891b2", "label": "Key"},
"num_predict": num_predict, {"id": 7, "glyph": "\u2731", "c1": "#fda4af", "c2": "#e11d48", "label": "Star"},
"num_ctx": num_ctx, {"id": 8, "glyph": "\U0001F3AF", "c1": "#a3e635", "c2": "#65a30d", "label": "Target"},
}, {"id": 9, "glyph": "\U0001F525", "c1": "#f87171", "c2": "#b91c1c", "label": "Flame"},
} {"id": 10, "glyph": "\U0001F31F", "c1": "#fbbf24", "c2": "#b45309", "label": "Glow"},
req = urllib.request.Request( {"id": 11, "glyph": "\U0001F9ED", "c1": "#5eead4", "c2": "#0d9488", "label": "Compass"},
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): # ── Ollama helpers ──
"""Remove a model-written trailing 'Sources:'/'References:' block (defensive).""" def _ollama_chat(model, messages, tools=None, num_ctx=16384, num_predict=1400,
temperature=0.2, timeout=180):
payload = {"model": model, "messages": messages, "stream": False, "think": False,
"options": {"temperature": temperature, "num_predict": num_predict, "num_ctx": num_ctx}}
if tools:
payload["tools"] = tools
req = urllib.request.Request(f"{OLLAMA_URL}/api/chat", data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
op = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with op.open(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
def _strip_sources(text):
text = re.split(r"\n\s*(?:Sources|References|Citations)\s*:\s*\n", text, flags=re.I)[0] 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() return text.strip()
def _build_answer(agent, message, history): # ── About Me injection (designated plainly, used silently) ──
top = rag.retrieve(message, agent["id"], top_k=5) def about_me_block(about_me):
if not top: if not about_me or not about_me.strip():
return { return ""
"answer": "I couldn't find relevant Washington law on that in my reference " return (
"library yet. Try rephrasing, or ask the Navigator to point you to " "\n\n=== ABOUT THE USER (PRIVATE CONTEXT) ===\n"
"the right specialist.", f"{about_me.strip()}\n"
"citations": [], "=== END ABOUT THE USER ===\n"
"grounded": False, "This is private background about the user's situation. Use it silently to tailor your "
} "answer to their facts. NEVER reference it, quote it, summarize it back, or say anything "
"like 'based on what you shared'. Answer ONLY what the user asks."
)
# ── Comms tools (agent can invoke to send SMS/email/call) ──
TOOLS = [
{"type": "function", "function": {
"name": "send_sms", "description": "Send a professionally-worded text message on the user's behalf.",
"parameters": {"type": "object", "properties": {
"recipient": {"type": "string", "description": "recipient phone number in E.164 (e.g. +14255550123)"},
"message": {"type": "string", "description": "the message text"}},
"required": ["recipient", "message"]}}},
{"type": "function", "function": {
"name": "send_email", "description": "Send a formal email on the user's behalf.",
"parameters": {"type": "object", "properties": {
"recipient": {"type": "string", "description": "recipient email address"},
"subject": {"type": "string"}, "body": {"type": "string"}},
"required": ["recipient", "subject", "body"]}}},
{"type": "function", "function": {
"name": "make_call", "description": "Place a phone call that speaks a message.",
"parameters": {"type": "object", "properties": {
"recipient": {"type": "string", "description": "recipient phone number in E.164"},
"message": {"type": "string", "description": "the message to speak"}},
"required": ["recipient", "message"]}}},
]
def _execute_tool(name, args, settings, uid, display_name):
recipient = args.get("recipient", "")
if comms.no_contact_blocked(settings, recipient):
return ("BLOCKED: a no-contact order is in effect for this person. No message was sent. "
"Inform the user you cannot contact this person due to the no-contact order.")
try:
if name == "send_sms":
comms.send_sms(settings, recipient, comms.sms_template(args.get("message", "")))
return f"SMS sent to {recipient}."
if name == "send_email":
body = comms.email_template(display_name, recipient, args.get("subject", ""), args.get("body", ""))
comms.send_email(settings, recipient, args.get("subject", ""), body)
return f"Email sent to {recipient}."
if name == "make_call":
twiml_url = request.host_url.rstrip("/") + "/twilio/voice"
comms.make_call(settings, recipient, twiml_url)
return f"Call initiated to {recipient}."
except Exception as e:
return f"ERROR sending {name}: {e}"
return "Unknown tool."
# ── RAG chat with tool-calling ──
def safety_block(settings):
if not (settings.get("domestic_violence") or settings.get("protection_order")):
return ""
lines = ["\n\n=== SAFETY CONTEXT (PRIVATE — CRITICAL) ==="]
if settings.get("domestic_violence"):
lines.append("DOMESTIC VIOLENCE IS A FACTOR IN THIS CASE.")
if settings.get("protection_order"):
lines.append("An active protection order is in effect (RCW 7.105).")
notes = (settings.get("safety_notes") or "").strip()
if notes:
lines.append(f"Safety notes: {notes}")
lines.append("=== END SAFETY CONTEXT ===")
lines.append(
"Prioritize the user's safety above all else. Apply RCW 26.09.191 restrictions on "
"residential time and decision-making where domestic violence or abuse is a factor. "
"Recommend protection orders, supervised visitation, and safety planning where "
"appropriate. NEVER advise or facilitate direct contact with the abusive or restrained party."
)
return "\n".join(lines)
def _build_answer(agent, message, history, about_me, settings, uid, display_name):
top = rag.retrieve(message, agent["id"], top_k=5)
context_blocks = [] context_blocks = []
citations = [] citations = []
seen = set() seen = set()
@@ -71,32 +146,27 @@ def _build_answer(agent, message, history):
continue continue
seen.add(key) seen.add(key)
context_blocks.append(f"[{i}] ({c['source']} — {c['title']})\n{c['text']}") context_blocks.append(f"[{i}] ({c['source']} — {c['title']})\n{c['text']}")
citations.append({ citations.append({"source": c["source"], "title": c["title"],
"source": c["source"], "score": round(score, 3), "snippet": c["text"][:260]})
"title": c["title"],
"score": round(score, 3),
"snippet": c["text"][:260] + ("…" if len(c["text"]) > 260 else ""),
})
system = ( system = (
f"{agent['system']}\n\n" f"{agent['system']}\n\n"
"Rules:\n" "Rules:\n"
"- Answer the user's question DIRECTLY and concisely. Begin your answer immediately — " "- Give REAL, practical legal advice: state the rule, apply it to the user's facts, "
"do NOT restate the question, do NOT narrate your reasoning, and do NOT say what you " "tell them what to do next.\n"
"are about to do.\n" "- Answer DIRECTLY. Do not restate the question or narrate your reasoning.\n"
"- Answer using ONLY the reference documents provided below.\n" "- Ground every legal claim in the reference documents. Cite the RCW section AND the "
"- Cite the RCW section (or source) inline for every legal claim, e.g. (RCW 26.09.030).\n" "controlling case law by name.\n"
"- If the documents do not contain the answer, say so clearly and suggest which " "- Be plain-English, specific to Washington State.\n"
"specialist or official resource to consult.\n" "- You may use the available tools (send_sms / send_email / make_call) ONLY when the "
"- Be practical and plain-English, specific to Washington State.\n" "user explicitly asks you to contact someone on their behalf.\n"
"- Do NOT write a 'Sources' list at the end; cite inline only." + about_me_block(about_me)
) + safety_block(settings)
user = (
"REFERENCE DOCUMENTS:\n\n"
+ "\n\n".join(context_blocks)
+ f"\n\nUSER QUESTION: {message}"
) )
user = ("REFERENCE DOCUMENTS:\n\n" + "\n\n".join(context_blocks)
+ f"\n\nUSER QUESTION: {message}")
messages = [{"role": "system", "content": system}] messages = [{"role": "system", "content": system}]
if history: if history:
for turn in history[-6:]: for turn in history[-6:]:
@@ -105,66 +175,263 @@ def _build_answer(agent, message, history):
messages.append({"role": "user", "content": user}) messages.append({"role": "user", "content": user})
try: try:
answer = _ollama_chat(RAG_MODEL, messages) resp = _ollama_chat(RAG_MODEL, messages, tools=TOOLS)
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: except Exception:
return { try:
"answer": "The legal engine is temporarily unavailable. Please try again in a " resp = _ollama_chat(GENERAL_MODEL, messages)
"moment. (Backend LLM could not be reached.)", except Exception:
"citations": [], return {"answer": "The legal engine is temporarily unavailable. Try again shortly.",
"grounded": False, "citations": [], "grounded": False}
}
answer = _strip_model_sources(answer) # tool-calling loop
return { for _ in range(3):
"answer": answer, msg = resp.get("message", {})
"citations": citations, tool_calls = msg.get("tool_calls") or []
"grounded": True, if not tool_calls:
"model": RAG_MODEL, break
} messages.append(msg)
for tc in tool_calls:
fn = tc.get("function", {})
name = fn.get("name", "")
args = fn.get("arguments", {})
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
result = _execute_tool(name, args, settings, uid, display_name)
messages.append({"role": "tool", "content": result})
resp = _ollama_chat(RAG_MODEL, messages, tools=TOOLS)
final = resp.get("message", {})
answer = (final.get("content") or final.get("thinking") or "").strip()
answer = _strip_sources(answer)
return {"answer": answer, "citations": citations, "grounded": bool(context_blocks)}
# ── Auth helpers ──
def _uid():
token = request.headers.get("Authorization", "").replace("Bearer ", "")
return store.user_from_token(token)
def _profile_or_none():
uid = _uid()
return uid, store.get_profile(uid) if uid else None, store.get_settings(uid) if uid else {}
# ── Pages ──
@app.route("/") @app.route("/")
def index(): def landing():
agents_public = [ return render_template("landing.html", agents=AGENTS)
{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") @app.route("/app")
def api_agents(): def app_page():
return jsonify([ return render_template("index.html", agents=AGENTS)
{k: a[k] for k in ("id", "name", "emoji", "tagline", "description", "accent")}
for a in AGENTS
])
# ── Auth API ──
@app.route("/api/register", methods=["POST"])
def api_register():
d = request.get_json(force=True, silent=True) or {}
u = (d.get("username") or "").strip().lower()
p = (d.get("password") or "").strip()
if len(u) < 2 or len(u) > 30 or not u.isalnum():
return jsonify({"error": "Username must be 2-30 alphanumeric chars"}), 400
if len(p) < 4:
return jsonify({"error": "Password must be 4+ chars"}), 400
c = store._conn()
if c.execute("SELECT id FROM users WHERE username=?", (u,)).fetchone():
c.close()
return jsonify({"error": "Username taken"}), 409
c.close()
uid = store.create_user(u, p)
return jsonify({"ok": True, "token": store.issue_token(uid), "username": u})
@app.route("/api/login", methods=["POST"])
def api_login():
d = request.get_json(force=True, silent=True) or {}
u = (d.get("username") or "").strip().lower()
p = (d.get("password") or "").strip()
uid = store.authenticate(u, p)
if not uid:
return jsonify({"error": "Invalid username or password"}), 401
return jsonify({"ok": True, "token": store.issue_token(uid), "username": u})
@app.route("/api/me")
def api_me():
uid = _uid()
if not uid:
return jsonify({"authenticated": False}), 401
return jsonify({"authenticated": True, "profile": store.get_profile(uid),
"settings": store.get_settings(uid)})
@app.route("/api/profile", methods=["GET", "POST"])
def api_profile():
uid = _uid()
if not uid:
return jsonify({"error": "auth required"}), 401
if request.method == "GET":
return jsonify(store.get_profile(uid))
d = request.get_json(force=True, silent=True) or {}
prof = store.set_profile(uid, display_name=d.get("display_name"),
avatar=d.get("avatar"), about_me=d.get("about_me"))
return jsonify(prof)
@app.route("/api/settings", methods=["GET", "POST"])
def api_settings():
uid = _uid()
if not uid:
return jsonify({"error": "auth required"}), 401
if request.method == "GET":
return jsonify(store.get_settings(uid))
d = request.get_json(force=True, silent=True) or {}
s = store.set_settings(uid, **d)
# never echo the auth token back in full
if "twilio_auth_token" in s:
s["twilio_auth_token"] = "••••" if s["twilio_auth_token"] else ""
if "smtp_pass" in s:
s["smtp_pass"] = "••••" if s["smtp_pass"] else ""
return jsonify(s)
@app.route("/api/avatars")
def api_avatars():
return jsonify(AVATARS)
# ── Chat ──
@app.route("/api/chat", methods=["POST"]) @app.route("/api/chat", methods=["POST"])
def api_chat(): def api_chat():
data = request.get_json(force=True, silent=True) or {} uid = _uid()
agent_id = (data.get("agent_id") or "navigator").strip() d = request.get_json(force=True, silent=True) or {}
message = (data.get("message") or "").strip() agent_id = (d.get("agent_id") or "navigator").strip()
history = data.get("history") or [] message = (d.get("message") or "").strip()
client_history = d.get("history") or []
agent = AGENT_BY_ID.get(agent_id) agent = AGENT_BY_ID.get(agent_id)
if not agent: if not agent:
return jsonify({"error": "Unknown agent"}), 400 return jsonify({"error": "Unknown agent"}), 400
if not message: if not message:
return jsonify({"error": "Message required"}), 400 return jsonify({"error": "Message required"}), 400
if len(message) > 4000:
message = message[:4000] message = message[:4000]
profile = store.get_profile(uid) if uid else {"about_me": "", "display_name": ""}
settings = store.get_settings(uid) if uid else {}
# persisted history for authed users; client history as fallback for anonymous
history = store.get_conversation(uid, agent_id) if uid else client_history
t0 = time.time() t0 = time.time()
result = _build_answer(agent, message, history) result = _build_answer(agent, message, history, profile.get("about_me", ""),
settings, uid, profile.get("display_name", ""))
if uid and result.get("answer"):
store.save_message(uid, agent_id, "user", message)
store.save_message(uid, agent_id, "assistant", result["answer"], result.get("citations"))
result["latency_ms"] = round((time.time() - t0) * 1000) result["latency_ms"] = round((time.time() - t0) * 1000)
result["agent"] = agent_id result["agent"] = agent_id
return jsonify(result) return jsonify(result)
@app.route("/api/conversation/<agent_id>", methods=["GET", "DELETE"])
def api_conversation(agent_id):
uid = _uid()
if not uid:
return jsonify({"error": "auth required"}), 401
if agent_id not in AGENT_BY_ID:
return jsonify({"error": "unknown agent"}), 400
if request.method == "DELETE":
store.clear_conversation(uid, agent_id)
return jsonify({"ok": True})
return jsonify({"history": store.get_conversation(uid, agent_id)})
# ── Documents ──
@app.route("/api/documents/types")
def api_doc_types():
return jsonify([{"id": k, "name": v["name"], "form": v["form"], "description": v["description"]}
for k, v in documents.DOC_TYPES.items()])
@app.route("/api/documents", methods=["POST"])
def api_documents():
uid = _uid()
d = request.get_json(force=True, silent=True) or {}
doc_type = (d.get("doc_type") or "").strip()
instructions = (d.get("instructions") or "").strip()
if doc_type not in documents.DOC_TYPES:
return jsonify({"error": "Unknown document type"}), 400
profile = store.get_profile(uid) if uid else {"about_me": ""}
# retrieve relevant context for grounding
top = rag.retrieve(documents.DOC_TYPES[doc_type]["name"], "divorce", top_k=4)
ctx = "\n\n".join(f"{c['text']}" for s, c in top)
try:
text = documents.generate(doc_type, profile.get("about_me", ""), instructions, ctx)
except Exception as e:
return jsonify({"error": f"generation failed: {e}"}), 500
return jsonify({"ok": True, "document": text,
"name": documents.DOC_TYPES[doc_type]["name"]})
# ── TTS ──
@app.route("/api/voices")
def api_voices():
return jsonify({"voices": tts.list_voices(), "default": "aria"})
@app.route("/api/speak", methods=["POST"])
def api_speak():
d = request.get_json(force=True, silent=True) or {}
text = (d.get("text") or "").strip()
voice = d.get("voice") or "aria"
if not text:
return jsonify({"error": "no text"}), 400
try:
path = tts.synthesize(text, voice)
except Exception as e:
return jsonify({"error": str(e)}), 500
return send_file(path, mimetype="audio/mpeg")
# ── Missions (manual comms) ──
@app.route("/api/mission", methods=["POST"])
def api_mission():
uid = _uid()
if not uid:
return jsonify({"error": "auth required"}), 401
d = request.get_json(force=True, silent=True) or {}
mtype = d.get("type")
recipient = d.get("recipient") or ""
settings = store.get_settings(uid)
profile = store.get_profile(uid)
if comms.no_contact_blocked(settings, recipient):
return jsonify({"ok": False, "error": "Blocked: a no-contact order is in effect for this person."}), 403
try:
if mtype == "sms":
comms.send_sms(settings, recipient, comms.sms_template(d.get("message", "")))
elif mtype == "email":
body = comms.email_template(profile.get("display_name", ""), recipient,
d.get("subject", ""), d.get("body", ""))
comms.send_email(settings, recipient, d.get("subject", ""), body)
elif mtype == "call":
comms.make_call(settings, recipient, request.host_url.rstrip("/") + "/twilio/voice")
else:
return jsonify({"ok": False, "error": "unknown type"}), 400
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
return jsonify({"ok": True})
# ── Twilio voice webhook (TwiML <Say>) ──
@app.route("/twilio/voice", methods=["GET", "POST"])
def twilio_voice():
msg = request.values.get("message", "This is an automated message from a legal assistant.")
twiml = (f'<?xml version="1.0" encoding="UTF-8"?>'
f'<Response><Say voice="Polly.Joanna">{msg}</Say></Response>')
return Response(twiml, mimetype="text/xml")
@app.route("/api/health") @app.route("/api/health")
def health(): def health():
return jsonify({"ok": True, "agent_count": len(AGENTS), "index": rag.index_status()}) return jsonify({"ok": True, "agent_count": len(AGENTS), "index": rag.index_status()})
@@ -184,9 +451,8 @@ def reindex():
return jsonify({"ok": False, "error": str(e)}), 500 return jsonify({"ok": False, "error": str(e)}), 500
# Kick off the background index build once at import (before serving). store.init_db()
rag.start_background_index() rag.start_background_index()
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, threaded=True) app.run(host="0.0.0.0", port=5000, threaded=True)

92
benchmark_models.py Normal file
View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Astraea model benchmark — feed identical retrieved facts to each candidate model
and compare grounded, practical legal answers. Picks the best RAG answer lane.
"""
import json
import os
import sys
import time
import urllib.request
sys.path.insert(0, "/opt/astraea")
import rag # reuse the retrieval pipeline (embed + cosine top-k)
OLLAMA = "http://10.30.20.29:11434"
OUT = "/opt/astraea/benchmark-results.json"
MODELS = [
"granite4.2:latest",
"qwen3.8fast:latest",
"ornith-1.5:9b",
"gemma4:26b-a4b-it-q4_K_M",
"nemotron-3.5-lightning:30b-a3b-q4_K_M",
]
# (agent_id, question) — each has a real answer in the current index.
QUESTIONS = [
("property", "My spouse bought a house before we married, but we paid the mortgage from our joint account for 15 years. When we divorce, is the house community or separate property?"),
("maintenance", "We're divorcing after a 22-year marriage. I stayed home to raise our kids and have no income. Am I entitled to spousal maintenance, and what factors does the court weigh?"),
("divorce", "What factors does a Washington court consider when deciding how to divide our property and debts, and can one spouse get more than half?"),
("divorce", "I just moved to Washington three weeks ago and want to file for divorce from my spouse who lives in Oregon. Can I file here, and is there a waiting period?"),
]
SYSTEM = (
"You are a licensed Washington State family-law attorney giving a real client direct, "
"practical legal advice. Base every legal claim on the reference documents. Cite the RCW "
"section AND any controlling case law by name. Be specific and actionable: state the rule, "
"apply it to the client's facts, and tell them what to do next. Do not hedge or refuse to "
"answer. If the exact scenario is not in the documents, give the closest governing principle "
"and note it needs attorney review."
)
def chat(model, system, user):
payload = {
"model": model,
"messages": [{"role": "system", "content": system},
{"role": "user", "content": user}],
"stream": False,
"think": False,
"options": {"temperature": 0.2, "num_predict": 1400, "num_ctx": 16384},
}
req = urllib.request.Request(f"{OLLAMA}/api/chat", data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
op = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with op.open(req, timeout=300) as r:
d = json.loads(r.read().decode("utf-8"))
m = d.get("message", {})
return (m.get("content") or m.get("thinking") or "").strip()
def main():
if not rag._INDEX:
print("loading index from cache...", flush=True)
rag._INDEX.extend(rag.build_index())
print(f"index: {len(rag._INDEX)} chunks", flush=True)
results = []
for agent_id, q in QUESTIONS:
top = rag.retrieve(q, agent_id, top_k=5)
ctx = "\n\n".join(
f"[{i+1}] ({c['source']} — {c['title']}):\n{c['text']}"
for i, (s, c) in enumerate(top)
)
print(f"\n=== Q: {q[:70]}... ({len(top)} chunks) ===", flush=True)
for model in MODELS:
t0 = time.time()
try:
ans = chat(model, SYSTEM, f"REFERENCE DOCUMENTS:\n\n{ctx}\n\nQUESTION: {q}")
dt = round(time.time() - t0, 1)
print(f" {model}: {len(ans)} chars in {dt}s", flush=True)
results.append({"question": q, "agent": agent_id, "model": model,
"answer": ans, "latency_s": dt,
"sources": [c["source"] for s, c in top]})
except Exception as e:
print(f" {model}: ERROR {e}", flush=True)
results.append({"question": q, "agent": agent_id, "model": model, "error": str(e)})
json.dump(results, open(OUT, "w"), indent=2)
print(f"\n=== done -> {OUT} ===")
if __name__ == "__main__":
main()

96
comms.py Normal file
View File

@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
"""Astraea comms — Twilio (SMS + voice calls) + SMTP email, with a no-contact-order
safety guard and formal message templates. stdlib-only (urllib + smtplib + email).
"""
import base64
import json
import smtplib
import urllib.parse
import urllib.request
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
TWILIO_API = "https://api.twilio.com/2010-04-01/Accounts"
def _twilio_request(settings, path, data):
sid = settings.get("twilio_sid", "")
token = settings.get("twilio_auth_token", "")
if not sid or not token:
raise RuntimeError("Twilio credentials not configured")
url = f"{TWILIO_API}/{sid}/{path}.json"
cred = base64.b64encode(f"{sid}:{token}".encode()).decode()
body = urllib.parse.urlencode(data).encode()
req = urllib.request.Request(url, data=body,
headers={"Authorization": f"Basic {cred}",
"Content-Type": "application/x-www-form-urlencoded"})
op = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with op.open(req, timeout=30) as r:
return json.loads(r.read().decode())
def send_sms(settings, to, body):
return _twilio_request(settings, "Messages",
{"To": to, "From": settings.get("twilio_from", ""), "Body": body})
def make_call(settings, to, twiml_url):
return _twilio_request(settings, "Calls",
{"To": to, "From": settings.get("twilio_from", ""), "Url": twiml_url})
def send_email(settings, to, subject, body):
host = settings.get("smtp_host", "")
user = settings.get("smtp_user", "")
pwd = settings.get("smtp_pass", "")
frm = settings.get("email_from", "") or user
if not host or not user or not frm:
raise RuntimeError("Email (SMTP) not configured")
port = int(settings.get("smtp_port", 587) or 587)
msg = MIMEMultipart()
msg["Subject"] = subject
msg["From"] = frm
msg["To"] = to
msg.attach(MIMEText(body, "plain", "utf-8"))
s = smtplib.SMTP(host, port, timeout=30)
s.starttls()
s.login(user, pwd)
s.sendmail(frm, [to], msg.as_string())
s.quit()
return {"ok": True, "to": to, "subject": subject}
# ── Formal templates (the agent composes content; these wrap it professionally) ──
def email_template(display_name, recipient, subject, body):
return (
f"{recipient},\n\n"
f"{body}\n\n"
f"Respectfully,\n"
f"{display_name}\n\n"
f"---\nThis message was drafted on Astraea, an AI legal assistant, on my behalf. "
f"Please direct any reply to me directly."
)
def sms_template(body):
return f"{body}\n\n— sent via Astraea legal assistant"
def call_script(greeting, purpose, body, closing):
return (
f"Good {greeting}. My name is [your name]. {purpose}.\n\n"
f"{body}\n\n"
f"{closing}"
)
def no_contact_blocked(settings, recipient):
"""Return True if a no-contact order OR protection order is in effect AND the recipient
looks like the protected/restrained party. Domestic-violence protection orders (RCW 7.105)
carry the same no-contact force as a standalone no-contact order."""
if not (settings.get("no_contact_order") or settings.get("protection_order")):
return False
partner = (settings.get("partner_name") or "").strip().lower()
if not partner:
return True # order in effect but name unset — block to be safe
return partner in (recipient or "").lower()

139
documents.py Normal file
View File

@@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
"""Astraea document generation — prep Washington divorce paperwork on demand.
Each doc type carries its real WA form structure; the LLM fills it with the user's facts.
"""
import urllib.request
import json
OLLAMA_URL = "http://10.30.20.29:11434"
# WA family-law paperwork the specialist can draft. `sections` mirrors the real form.
DOC_TYPES = {
"petition": {
"name": "Petition for Dissolution of Marriage",
"form": "FL Divorce 201",
"description": "The filing that starts a divorce — party info, marriage details, grounds, and requests.",
"sections": [
"Caption (Superior Court of Washington, County of ___)",
"Parties (Petitioner / Respondent names)",
"Marriage information (date + place of marriage)",
"Residency basis (RCW 26.09.030)",
"Grounds: irretrievably broken",
"Children of the marriage (names, ages)",
"Requests: dissolution, property division, maintenance, parenting plan, fees",
],
},
"summons": {
"name": "Summons (Dissolution)",
"form": "FL Divorce 200",
"description": "Formal notice to the other spouse that a case has been filed and they must respond.",
"sections": [
"Caption", "Notice of case filing", "Response deadline (20/60 days)",
"Warning: default judgment if no response", "Certificate of service",
],
},
"parenting-plan": {
"name": "Parenting Plan",
"form": "FL All Family 140",
"description": "The residential schedule and decision-making plan (RCW 26.09.184).",
"sections": [
"Children covered", "Residential schedule (school year / holidays / summer)",
"Decision-making (joint or sole, RCW 26.09.187)", "Dispute resolution",
"Relocation restrictions (RCW 26.09.405-.560)", "Safety provisions if DV (RCW 26.09.191)",
],
},
"child-support": {
"name": "Child Support Order + Worksheet",
"form": "WSCSS / FL All Family 130",
"description": "The income-shares support calculation under RCW 26.19.",
"sections": [
"Combined monthly net income (each parent)", "Economic table lookup (RCW 26.19.020)",
"Basic support obligation", "Proportionate share split",
"Transfer payment amount", "Healthcare + special expenses", "Deviations (if any)",
],
},
"financial": {
"name": "Financial Declaration",
"form": "FL All Family 131",
"description": "Full disclosure of income, assets, debts, and expenses.",
"sections": ["Income (all sources)", "Assets (real + personal)", "Debts/liabilities",
"Monthly living expenses", "Separate property claims"],
},
"final-order": {
"name": "Findings of Fact, Conclusions of Law & Decree",
"form": "FL Divorce 241",
"description": "The final order that ends the marriage and sets the terms.",
"sections": [
"Findings of fact (residency, marriage, children)", "Conclusions of law (grounds)",
"Property + debt division (RCW 26.09.080)", "Maintenance award (RCW 26.09.090)",
"Parenting plan incorporation", "Child support order", "Name restoration",
"Attorney fees (RCW 26.09.140)",
],
},
"settlement": {
"name": "Separation / Settlement Agreement",
"form": "RCW 26.09.070",
"description": "A binding written agreement resolving all issues without trial.",
"sections": ["Recitals", "Property + debt division", "Maintenance",
"Parenting + support", "Disclosure of assets", "Mutual release",
"Enforcement + severability"],
},
"confidential": {
"name": "Confidential Information Form",
"form": "FL All Family 001",
"description": "Sealed identifying info (SSNs, DOBs) filed with every case.",
"sections": ["Party names + DOB + SSN", "Children (names/DOB)", "Protective address (if DV)"],
},
"letter": {
"name": "Formal Correspondence",
"form": "custom",
"description": "A professional letter to a spouse, the court, or counsel.",
"sections": ["Date + address", "Salutation", "Body (purpose)", "Closing + signature"],
},
}
def build_prompt(doc_type, about_me, instructions, context):
dt = DOC_TYPES.get(doc_type)
if not dt:
return None, None
sections = "\n".join(f"- {s}" for s in dt["sections"])
system = (
"You are a meticulous Washington State family-law paralegal preparing court documents. "
"Draft the requested document as a clean, complete, court-ready text. Use the user's "
"ABOUT ME details and any reference context to fill in every field you can; where a "
"fact is unknown, insert a blank '[____]' placeholder — never invent names, dates, or "
"numbers. Follow the real Washington form structure precisely. Do not add legal advice "
"to the document itself."
)
user = (
f"DOCUMENT: {dt['name']} (form {dt['form']})\n"
f"PURPOSE: {dt['description']}\n\n"
f"REQUIRED SECTIONS:\n{sections}\n\n"
f"ABOUT ME (the client):\n{about_me or '(none provided)'}\n\n"
f"SPECIFIC INSTRUCTIONS:\n{instructions or '(draft a complete standard version)'}\n\n"
f"REFERENCE (Washington law):\n{context}\n\n"
"Draft the full document now, formatted for print."
)
return system, user
def generate(doc_type, about_me, instructions, context):
system, user = build_prompt(doc_type, about_me, instructions, context)
if not system:
return None
payload = {
"model": "ornith-1.5:9b",
"messages": [{"role": "system", "content": system},
{"role": "user", "content": user}],
"stream": False, "think": False,
"options": {"temperature": 0.2, "num_predict": 3000, "num_ctx": 24000},
}
req = urllib.request.Request(f"{OLLAMA_URL}/api/chat",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
op = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with op.open(req, timeout=360) as r:
d = json.loads(r.read().decode("utf-8"))
m = d.get("message", {})
return (m.get("content") or m.get("thinking") or "").strip()

211
polish.py Normal file
View File

@@ -0,0 +1,211 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Astraea Polisher v2 — recursive qwen3.8fast self-improvement loop.
Goal: make the most visually appealing, premium, cinematic site possible.
Each iteration: read frontend -> qwen3.8fast critiques + rewrites toward GOAL
(contract = Jinja + chat API + external links) -> validate (JS/HTML id cross-check)
-> backup -> apply -> curl-verify -> auto-rollback on failure -> log. Retries a
failed generation up to 2x with targeted feedback.
"""
import json
import os
import re
import sys
import subprocess
import time
import urllib.request
from datetime import datetime
OLLAMA = "http://10.30.20.29:11434"
MODEL = "qwen3.8fast:latest"
TEMPLATE = "/opt/astraea/templates/index.html"
BACKUP_DIR = "/opt/astraea/polish-backups"
LOG_PATH = "/opt/astraea/polish-log.json"
ITERATIONS = int(sys.argv[1]) if len(sys.argv) > 1 else 6
GOAL = (
"Make this the most visually stunning, premium, cinematic, and emotionally "
"compelling family-law website on the internet. Obsess over typography "
"(scale, weight, letter-spacing), a refined dark color palette with a "
"confident accent, spacing and rhythm, motion and micro-interactions, depth "
"(gradients, glows, glass), and copy that is authoritative yet human. Target "
"the feel of a top-tier law firm's flagship site crossed with a futuristic AI "
"product — dark, elegant, trustworthy, memorable. Every iteration must be "
"strictly MORE appealing than the last."
)
CONTRACT = (
"HARD REQUIREMENTS — preserve these EXACTLY or the rewrite is invalid:\n"
"1. Keep the Jinja placeholders verbatim: `{{ agents | tojson }}` and "
"`{{ disclaimer | tojson }}`.\n"
"2. Keep `const AGENTS = {{ agents | tojson }};` and "
"`const DISCLAIMER = {{ disclaimer | tojson }};`.\n"
"3. Keep `fetch('/api/chat', ...)` and the request body {agent_id, message, history}.\n"
"4. Keep the Umami beacon `<script async src=\"https://analytics.thetempleofdoom.com/script.js\" "
"data-website-id=\"c8c2f69c-4448-40ab-9848-02179f10c001\"></script>`.\n"
"5. Keep the BMAC footer link to https://buymeacoffee.com/r26xrthzttg.\n"
"6. Keep every element id that the JavaScript references via getElementById / querySelector "
"matching the SAME id in the HTML (you may rename, but keep them consistent).\n"
"7. Output ONLY the complete valid HTML document (from <!DOCTYPE html> to </html>), "
"with no code fences and no commentary."
)
def _p(*a, **k):
print(*a, **k, flush=True)
def chat(system, user, max_tokens=14000, temperature=0.65):
payload = {
"model": MODEL,
"messages": [{"role": "system", "content": system},
{"role": "user", "content": user}],
"stream": False, "think": False,
"options": {"temperature": temperature, "num_predict": max_tokens, "num_ctx": 24000},
}
req = urllib.request.Request(f"{OLLAMA}/api/chat", data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
op = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with op.open(req, timeout=360) as r:
d = json.loads(r.read().decode("utf-8"))
m = d.get("message", {})
return (m.get("content") or m.get("thinking") or "").strip()
def extract_html(text):
"""Pull the HTML document out of a model reply (strip fences + prose)."""
# strip a code fence if present (any language tag)
m = re.search(r"```\w*\s*\n?(.*?)```", text, re.DOTALL)
if m:
text = m.group(1)
# find the <html ...> ... </html> block (with or without DOCTYPE)
m = re.search(r"(<html[^>]*>.*?</html>)", text, re.DOTALL | re.IGNORECASE)
if m:
html = m.group(1)
if not html.lower().lstrip().startswith("<!doctype"):
html = "<!DOCTYPE html>\n" + html
return html.strip()
return ""
def validate(html):
"""Return (ok, [problems]). External contract + internal JS/HTML id consistency."""
problems = []
if "{{ agents | tojson }}" not in html:
problems.append("missing {{ agents | tojson }}")
if "{{ disclaimer | tojson }}" not in html:
problems.append("missing {{ disclaimer | tojson }}")
if "const AGENTS =" not in html:
problems.append("missing const AGENTS")
if "const DISCLAIMER =" not in html:
problems.append("missing const DISCLAIMER")
if "fetch('/api/chat'" not in html:
problems.append("missing fetch('/api/chat')")
if "c8c2f69c-4448-40ab-9848-02179f10c001" not in html:
problems.append("missing Umami id")
if "buymeacoffee.com/r26xrthzttg" not in html:
problems.append("missing BMAC link")
for tag in ("html", "body", "script", "style"):
if html.count(f"<{tag}") != html.count(f"</{tag}>"):
problems.append(f"unbalanced <{tag}> tags")
# JS/HTML id cross-check
scripts = "\n".join(re.findall(r"<script[^>]*>(.*?)</script>", html, re.DOTALL))
refs = set(re.findall(r"getElementById\(['\"]([^'\"]+)['\"]\)", scripts))
refs |= set(re.findall(r"querySelector\(['\"]#([^'\"]+)['\"]\)", scripts))
refs |= set(re.findall(r"querySelectorAll\(['\"]#([^'\"]+)['\"]\)", scripts))
for r in refs:
if not re.search(rf"id\s*=\s*[\"']{re.escape(r)}[\"']", html):
problems.append(f"JS refs #{r} but no matching id in HTML")
return (len(problems) == 0, problems)
def shell(cmd):
return subprocess.run(cmd, shell=True, capture_output=True, text=True)
def verify_live():
"""Check the served page returns 200 (stdlib urllib, no curl on the CT)."""
try:
req = urllib.request.Request("http://127.0.0.1:5000/")
op = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with op.open(req, timeout=12) as r:
return r.status == 200
except Exception:
return False
def log_entry(entry):
log = []
if os.path.exists(LOG_PATH):
try:
log = json.load(open(LOG_PATH))
except Exception:
log = []
log.append(entry)
json.dump(log, open(LOG_PATH, "w"), indent=2)
def main():
os.makedirs(BACKUP_DIR, exist_ok=True)
_p(f"=== Astraea Polisher v2 — {ITERATIONS} iterations, {MODEL} ===")
_p(f"GOAL: {GOAL[:80]}...")
for it in range(1, ITERATIONS + 1):
_p(f"\n--- iteration {it}/{ITERATIONS} ---")
current = open(TEMPLATE, encoding="utf-8").read()
system = f"You are a world-class front-end design engineer. OBJECTIVE: {GOAL}\n\n{CONTRACT}"
applied = False
problems = []
for attempt in range(1, 4):
feedback = ""
if attempt > 1:
feedback = f"\n\nYour previous attempt was REJECTED for: {problems}. Fix ONLY those issues and resubmit the complete HTML."
user = (
"Here is the CURRENT full HTML/CSS/JS. Rewrite it into a COMPLETE, "
"significantly more appealing HTML document. Output ONLY the HTML — no "
"commentary, no explanation, no code fences."
f"{feedback}\n\n=== CURRENT HTML ===\n{current}"
)
try:
reply = chat(system, user)
except Exception as e:
_p(f" LLM error: {e}")
break
new_html = extract_html(reply)
if not new_html:
_p(f" attempt {attempt}: could not extract HTML (reply len {len(reply)})")
continue
ok, problems = validate(new_html)
if not ok:
_p(f" attempt {attempt}: validation failed ({len(problems)}): {problems[:4]}")
continue
# backup + apply
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
backup = os.path.join(BACKUP_DIR, f"index.html.bak-{ts}")
open(backup, "w", encoding="utf-8").write(current)
open(TEMPLATE, "w", encoding="utf-8").write(new_html)
shell("systemctl restart astraea")
time.sleep(4)
if verify_live():
_p(f" ✓ applied ({len(new_html)-len(current):+d} chars) — {backup}")
log_entry({"ts": ts, "iter": it, "attempt": attempt,
"delta_chars": len(new_html) - len(current)})
applied = True
else:
open(TEMPLATE, "w", encoding="utf-8").write(current)
shell("systemctl restart astraea")
_p(" ✗ live verify failed — rolled back")
log_entry({"ts": ts, "iter": it, "rollback": True})
break
if not applied:
_p(" (no change this iteration)")
_p(f"\n=== done. log: {LOG_PATH} ===")
if __name__ == "__main__":
main()

BIN
static/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

205
store.py Normal file
View File

@@ -0,0 +1,205 @@
# -*- coding: utf-8 -*-
"""Astraea data layer — no-KYC auth, profiles (avatar + About Me), and settings.
SQLite at /opt/astraea/astraea.db. stdlib-only.
"""
import hashlib
import json
import secrets
import sqlite3
DB_PATH = "/opt/astraea/astraea.db"
def _conn():
c = sqlite3.connect(DB_PATH)
c.row_factory = sqlite3.Row
return c
def init_db():
c = _conn()
c.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS profiles (
user_id INTEGER PRIMARY KEY,
display_name TEXT DEFAULT '',
avatar INTEGER DEFAULT 0,
about_me TEXT DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS settings (
user_id INTEGER PRIMARY KEY,
twilio_sid TEXT DEFAULT '',
twilio_auth_token TEXT DEFAULT '',
twilio_from TEXT DEFAULT '',
smtp_host TEXT DEFAULT '',
smtp_port INTEGER DEFAULT 587,
smtp_user TEXT DEFAULT '',
smtp_pass TEXT DEFAULT '',
email_from TEXT DEFAULT '',
no_contact_order INTEGER DEFAULT 0,
partner_name TEXT DEFAULT '',
domestic_violence INTEGER DEFAULT 0,
protection_order INTEGER DEFAULT 0,
safety_notes TEXT DEFAULT '',
tts_voice TEXT DEFAULT 'aria'
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
agent_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
citations TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
c.commit()
# migrations for pre-existing DBs
for col, ddl in [("domestic_violence", "INTEGER DEFAULT 0"),
("protection_order", "INTEGER DEFAULT 0"),
("safety_notes", "TEXT DEFAULT ''")]:
try:
c.execute(f"ALTER TABLE settings ADD COLUMN {col} {ddl}")
c.commit()
except Exception:
pass
c.close()
def hash_password(pw):
return hashlib.sha256(pw.encode()).hexdigest()
def user_from_token(token):
if not token:
return None
c = _conn()
row = c.execute("SELECT user_id FROM sessions WHERE token=?", (token,)).fetchone()
c.close()
return row["user_id"] if row else None
def create_user(username, password):
c = _conn()
c.execute("INSERT INTO users (username, password_hash) VALUES (?,?)",
(username, hash_password(password)))
c.commit()
uid = c.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()["id"]
c.execute("INSERT OR IGNORE INTO profiles (user_id) VALUES (?)", (uid,))
c.execute("INSERT OR IGNORE INTO settings (user_id) VALUES (?)", (uid,))
c.commit()
c.close()
return uid
def issue_token(uid):
token = secrets.token_hex(32)
c = _conn()
c.execute("INSERT INTO sessions (token, user_id) VALUES (?,?)", (token, uid))
c.commit()
c.close()
return token
def authenticate(username, password):
c = _conn()
row = c.execute("SELECT id, password_hash FROM users WHERE username=?",
(username,)).fetchone()
c.close()
if row and row["password_hash"] == hash_password(password):
return row["id"]
return None
def get_profile(uid):
c = _conn()
row = c.execute("SELECT * FROM profiles WHERE user_id=?", (uid,)).fetchone()
c.close()
if not row:
return {"display_name": "", "avatar": 0, "about_me": ""}
return {"display_name": row["display_name"], "avatar": row["avatar"],
"about_me": row["about_me"]}
def set_profile(uid, display_name=None, avatar=None, about_me=None):
cur = get_profile(uid)
dn = cur["display_name"] if display_name is None else display_name
av = cur["avatar"] if avatar is None else avatar
am = cur["about_me"] if about_me is None else about_me
c = _conn()
c.execute("""INSERT INTO profiles (user_id, display_name, avatar, about_me, updated_at)
VALUES (?,?,?,?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
display_name=excluded.display_name, avatar=excluded.avatar,
about_me=excluded.about_me, updated_at=CURRENT_TIMESTAMP""",
(uid, dn, av, am))
c.commit()
c.close()
return {"display_name": dn, "avatar": av, "about_me": am}
def get_settings(uid):
c = _conn()
row = c.execute("SELECT * FROM settings WHERE user_id=?", (uid,)).fetchone()
c.close()
if not row:
return {}
# strip auth secrets from the read-back for safety, but return everything to owner
return dict(row)
def save_message(uid, agent_id, role, content, citations=None):
c = _conn()
c.execute("INSERT INTO messages (user_id, agent_id, role, content, citations) VALUES (?,?,?,?,?)",
(uid, agent_id, role, content, json.dumps(citations or [])))
c.commit()
c.close()
def get_conversation(uid, agent_id, limit=60):
c = _conn()
rows = c.execute(
"SELECT role, content, citations FROM messages WHERE user_id=? AND agent_id=? ORDER BY id DESC LIMIT ?",
(uid, agent_id, limit)).fetchall()
c.close()
out = []
for r in reversed(rows):
try:
cit = json.loads(r["citations"]) if r["citations"] else []
except Exception:
cit = []
out.append({"role": r["role"], "content": r["content"], "citations": cit})
return out
def clear_conversation(uid, agent_id):
c = _conn()
c.execute("DELETE FROM messages WHERE user_id=? AND agent_id=?", (uid, agent_id))
c.commit()
c.close()
def set_settings(uid, **fields):
allowed = {"twilio_sid", "twilio_auth_token", "twilio_from", "smtp_host", "smtp_port",
"smtp_user", "smtp_pass", "email_from", "no_contact_order", "partner_name",
"domestic_violence", "protection_order", "safety_notes", "tts_voice"}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
return get_settings(uid)
cols = ", ".join(f"{k}=?" for k in updates)
c = _conn()
c.execute(f"UPDATE settings SET {cols} WHERE user_id=?", (*updates.values(), uid))
c.commit()
c.close()
return get_settings(uid)

View File

@@ -3,406 +3,333 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Astraea — Washington Family Law, Decoded</title> <title>Astraea — Your Attorney</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> <style>
:root { :root { --bg:#070b1a; --panel:#101832; --panel2:#141d3d; --line:#233055; --text:#e8edfb; --muted:#8b98c4; --gold:#f5d06f; }
--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; } * { 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(--bg) 60%); color:var(--text); min-height:100vh; }
body { canvas { position:fixed; inset:0; z-index:0; pointer-events:none; }
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; .app { position:relative; z-index:1; max-width:1120px; margin:0 auto; padding:0 20px 60px; }
background: radial-gradient(1200px 700px at 50% -10%, #18254f 0%, var(--bg2) 45%, var(--bg) 100%); header { padding:20px 0; display:flex; align-items:center; justify-content:space-between; gap:14px; }
color: var(--text); .brand { display:flex; align-items:center; gap:11px; }
overflow-x: hidden; .logo { width:40px; height:40px; border-radius:11px; background:linear-gradient(135deg,#6ea8ff,#a78bfa); display:grid; place-items:center; font-size:20px; }
-webkit-font-smoothing: antialiased; .brand h1 { font-size:20px; font-weight:800; }
}
#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 h1 span { color:var(--gold); }
.brand .sub { color: var(--muted); font-size: 13px; margin-top: 2px; letter-spacing: .3px; } .top-actions { display:flex; align-items:center; gap:10px; }
.pill { .avatar-sm { width:36px; height:36px; border-radius:50%; display:grid; place-items:center; font-size:17px; color:#fff; cursor:pointer; }
font-size: 12px; color: var(--muted); border: 1px solid var(--line); .btn { border:1px solid var(--line); background:var(--panel2); color:var(--text); border-radius:10px; padding:9px 15px; font-size:13.5px; font-weight:600; cursor:pointer; transition:.15s; }
padding: 6px 12px; border-radius: 999px; display: inline-flex; align-items: center; gap: 7px; .btn:hover { border-color:#3b82f6; }
} .btn.primary { background:linear-gradient(135deg,#3b82f6,#6366f1); border:none; color:#fff; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 10px #4ade80; } .hero { padding:12px 0 20px; }
.hero h2 { font-size:clamp(24px,4vw,34px); font-weight:800; letter-spacing:-.5px; }
.hero p { color:var(--muted); margin-top:8px; font-size:15px; }
.label { font-size:11.5px; text-transform:uppercase; letter-spacing:2px; color:var(--muted); margin:14px 0 12px; }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(235px,1fr)); gap:13px; }
.card { background:linear-gradient(180deg,var(--panel2),var(--panel)); border:1px solid var(--line); border-radius:15px; padding:16px; cursor:pointer; position:relative; overflow:hidden; transition:.18s; }
.card::before { content:""; position:absolute; inset:0 0 auto 0; height:3px; background:var(--c,#7dd3fc); }
.card:hover { transform:translateY(-3px); border-color:var(--c,#7dd3fc); }
.card.active { border-color:var(--c,#7dd3fc); box-shadow:0 0 0 1px var(--c,#7dd3fc),0 14px 34px rgba(0,0,0,.5); }
.card .emoji { font-size:27px; }
.card h3 { font-size:15.5px; font-weight:700; margin:10px 0 3px; }
.card .tag { font-size:12px; color:var(--c,#7dd3fc); font-weight:600; margin-bottom:6px; }
.card p { font-size:12.5px; color:var(--muted); line-height:1.45; }
.hero { padding: 26px 0 34px; max-width: 720px; } .chat-panel { margin-top:30px; background:linear-gradient(180deg,var(--panel2),var(--panel)); border:1px solid var(--line); border-radius:18px; display:flex; flex-direction:column; overflow:hidden; }
.hero h2 { font-size: clamp(28px, 5vw, 46px); line-height: 1.12; font-weight: 800; letter-spacing: -.5px; } .chat-head { padding:14px 18px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:11px; flex-wrap:wrap; }
.hero h2 .grad { .chat-head .emoji { font-size:24px; }
background: linear-gradient(90deg, #7dd3fc, #a78bfa, #f0abfc); .chat-head .who { font-weight:700; font-size:15px; }
-webkit-background-clip: text; background-clip: text; color: transparent; .chat-head .scope { color:var(--muted); font-weight:500; font-size:12px; }
} .chips { margin-left:auto; display:flex; gap:6px; flex-wrap:wrap; }
.hero p { color: var(--muted); font-size: 16.5px; line-height: 1.6; margin-top: 14px; max-width: 640px; } .chip { font-size:11.5px; padding:5px 10px; border-radius:999px; border:1px solid var(--line); color:var(--muted); background:transparent; cursor:pointer; white-space:nowrap; }
.chip.on { color:#06121f; background:var(--c,#7dd3fc); border-color:var(--c,#7dd3fc); font-weight:700; }
.section-label { font-size: 12px; text-transform: uppercase; letter-spacing: 2px; color: var(--muted); margin: 10px 0 16px; } .messages { padding:20px; min-height:220px; max-height:520px; overflow-y:auto; display:flex; flex-direction:column; gap:15px; }
.msg { max-width:80%; display:flex; flex-direction:column; }
.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.user { align-self:flex-end; align-items:flex-end; }
.msg.agent { align-self: flex-start; align-items: flex-start; } .msg.agent { align-self:flex-start; }
.bubble { .bubble { padding:12px 15px; border-radius:15px; font-size:14.5px; line-height:1.6; white-space:pre-wrap; word-break:break-word; }
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.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.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; } .msg .who-label { font-size:11px; color:var(--muted); margin:0 4px 4px; }
.speak { margin-top:6px; background:none; border:none; color:var(--gold); cursor:pointer; font-size:12px; }
.citations { margin-top: 10px; width: 100%; } .citations { margin-top:9px; }
.cite-toggle { .cite-toggle { font-size:12px; color:var(--gold); cursor:pointer; background:none; border:none; }
font-size: 12px; color: var(--gold); cursor: pointer; border: none; background: none; .cite-list { margin-top:7px; display:none; flex-direction:column; gap:7px; }
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-list.open { display:flex; }
.cite { .cite { background:#0c142e; border:1px solid var(--line); border-radius:9px; padding:9px 11px; font-size:12px; color:var(--muted); }
background: #0c142e; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; .cite b { color:var(--text); display:block; margin-bottom:2px; }
font-size: 12.5px; color: var(--muted); .typing { display:inline-flex; gap:5px; padding:6px 2px; }
} .typing i { width:7px; height:7px; border-radius:50%; background:var(--muted); animation:bl 1.2s infinite; }
.cite b { color: var(--text); display: block; margin-bottom: 3px; font-size: 13px; } .typing i:nth-child(2){ animation-delay:.2s; } .typing i:nth-child(3){ animation-delay:.4s; }
.cite .src { color: var(--gold); font-size: 11px; } @keyframes bl { 0%,100%{opacity:.25;} 50%{opacity:1;} }
.composer { border-top:1px solid var(--line); padding:13px 15px; display:flex; gap:9px; align-items:flex-end; }
.composer textarea { flex:1; resize:none; background:#0c142e; border:1px solid var(--line); color:var(--text); border-radius:11px; padding:11px 13px; font-size:14.5px; font-family:inherit; line-height:1.5; max-height:130px; outline:none; }
.composer textarea:focus { border-color:var(--c,#7dd3fc); }
.send { border:none; border-radius:11px; padding:12px 18px; font-size:14px; font-weight:700; color:#06121f; background:var(--c,#7dd3fc); cursor:pointer; }
.send:disabled { opacity:.45; }
.tools-row { display:flex; gap:8px; padding:0 15px 12px; flex-wrap:wrap; }
.tools-row .btn { font-size:12.5px; padding:8px 13px; }
.typing { display: inline-flex; gap: 5px; align-items: center; padding: 6px 2px; } .modal-bg { position:fixed; inset:0; background:rgba(3,5,12,.8); z-index:50; display:none; align-items:center; justify-content:center; padding:20px; }
.typing i { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); animation: blink 1.2s infinite; } .modal-bg.open { display:flex; }
.typing i:nth-child(2) { animation-delay: .2s; } .modal { background:linear-gradient(180deg,var(--panel2),var(--panel)); border:1px solid var(--line); border-radius:18px; width:100%; max-width:620px; max-height:90vh; overflow-y:auto; padding:26px; }
.typing i:nth-child(3) { animation-delay: .4s; } .modal h2 { font-size:20px; font-weight:800; margin-bottom:4px; }
@keyframes blink { 0%,100% { opacity: .25; } 50% { opacity: 1; } } .modal .sub { color:var(--muted); font-size:13px; margin-bottom:18px; }
.modal .close { float:right; background:none; border:none; color:var(--muted); font-size:22px; cursor:pointer; }
.composer { border-top: 1px solid var(--line); padding: 14px 16px; display: flex; gap: 10px; align-items: flex-end; } .fld { margin-bottom:15px; }
.composer textarea { .fld label { display:block; font-size:12.5px; color:var(--muted); margin-bottom:6px; }
flex: 1; resize: none; background: #0c142e; border: 1px solid var(--line); color: var(--text); .fld input, .fld textarea, .fld select { width:100%; background:#0c142e; border:1px solid var(--line); color:var(--text); border-radius:10px; padding:11px 13px; font-size:14px; outline:none; font-family:inherit; }
border-radius: 12px; padding: 12px 14px; font-size: 15px; font-family: inherit; line-height: 1.5; .fld textarea { min-height:110px; resize:vertical; }
max-height: 140px; outline: none; transition: border-color .15s ease; .fld input:focus, .fld textarea:focus { border-color:#6366f1; }
} .avatar-grid { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-bottom:18px; }
.composer textarea:focus { border-color: var(--c, var(--accent)); } .avatar-opt { aspect-ratio:1; border-radius:50%; display:grid; place-items:center; font-size:22px; color:#fff; cursor:pointer; border:2px solid transparent; }
.send { .avatar-opt.sel { border-color:#fff; box-shadow:0 0 0 3px rgba(255,255,255,.2); }
border: none; border-radius: 12px; padding: 13px 20px; font-size: 15px; font-weight: 700; .switch { display:flex; align-items:center; gap:10px; }
color: #06121f; background: var(--c, var(--accent)); cursor: pointer; transition: transform .1s ease, opacity .2s; .switch input { width:auto; }
} .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:13px; }
.send:active { transform: scale(.96); } .doc-result { background:#0c142e; border:1px solid var(--line); border-radius:12px; padding:16px; font-size:13px; white-space:pre-wrap; max-height:50vh; overflow-y:auto; line-height:1.6; margin-top:14px; }
.send:disabled { opacity: .45; cursor: default; } footer { text-align:center; margin-top:26px; font-size:12px; color:var(--muted); }
.hint { font-size: 11.5px; color: var(--muted); text-align: center; padding: 0 22px 14px; } footer a { color:var(--muted); }
@media (max-width:640px){ .msg{max-width:92%;} .grid2{grid-template-columns:1fr;} .avatar-grid{grid-template-columns:repeat(4,1fr);} }
.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> </style>
</head> </head>
<body> <body>
<canvas id="stars"></canvas> <canvas id="stars"></canvas>
<div id="mascot">✦</div> <div class="app">
<div class="wrap">
<header> <header>
<div class="brand"> <div class="brand"><div class="logo">⚖️</div><div><h1>ASTRA<span>EA</span></h1></div></div>
<div class="logo">⚖️</div> <div class="top-actions">
<div> <button class="btn" onclick="openDocs()">📄 Documents</button>
<h1>ASTRA<span>EA</span></h1> <button class="btn" onclick="openSettings()">⚙️ Settings</button>
<div class="sub">Washington Family Law, decoded by specialist agents</div> <div class="avatar-sm" id="myAvatar" onclick="openProfile()" style="background:linear-gradient(135deg,#6ea8ff,#a78bfa)">⚖</div>
</div> </div>
</div>
<div class="pill"><span class="dot"></span> <span id="statusText">Engines online</span></div>
</header> </header>
<section class="hero"> <div class="hero">
<h2>Eight specialists.<br>One <span class="grad">constellation</span> of Washington family law.</h2> <h2>Eight specialists. Your case.</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> <p>Ask anything. Every answer is grounded in Washington statute and verified case law — and it already knows your situation.</p>
</section> </div>
<div class="section-label">Choose your specialist</div> <div class="label">Choose your specialist</div>
<div class="grid" id="grid"></div> <div class="grid" id="grid"></div>
<section class="chat-panel" id="chatPanel"> <section class="chat-panel" id="chatPanel">
<div class="chat-head"> <div class="chat-head">
<div class="emoji" id="headEmoji">🧭</div> <div class="emoji" id="headEmoji">🧭</div>
<div> <div><div class="who" id="headName">Navigator</div><div class="scope" id="headScope">Where to start</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 class="chips" id="chips"></div>
</div> </div>
<div class="messages" id="messages"></div> <div class="messages" id="messages"></div>
<div class="tools-row">
<button class="btn" onclick="openDocs()">📄 Prep a document</button>
<button class="btn" onclick="openMission()">📨 Send on my behalf</button>
<button class="btn" onclick="clearConv()">🗑 Clear chat</button>
</div>
<div class="composer"> <div class="composer">
<textarea id="input" rows="1" placeholder="Ask a Washington family-law question…"></textarea> <button class="btn" id="micBtn" onclick="toggleMic()" title="Speak" style="flex:0 0 auto">🎤</button>
<textarea id="input" rows="1" placeholder="Ask your attorney…"></textarea>
<button class="send" id="sendBtn">Ask</button> <button class="send" id="sendBtn">Ask</button>
</div> </div>
<div class="hint">Answers cite the Revised Code of Washington. This is legal information, not advice.</div>
</section> </section>
<div class="disclaimer">{{ disclaimer }}</div> <footer>Astraea gives practical legal guidance grounded in Washington law — not a substitute for a licensed attorney. · <a href="https://buymeacoffee.com/r26xrthzttg" target="_blank" rel="noopener">☕ Support</a></footer>
<footer>
<a class="bmac" href="https://buymeacoffee.com/r26xrthzttg" target="_blank" rel="noopener">
<span class="cup">☕</span> Support Astraea
</a>
</footer>
</div> </div>
<!-- Profile modal -->
<div class="modal-bg" id="profileModal"><div class="modal">
<button class="close" onclick="closeModal('profileModal')">×</button>
<h2>Your profile</h2><div class="sub">This context shapes every answer — your attorney already knows your situation.</div>
<div class="fld"><label>Display name</label><input id="pfName" placeholder="Your name"></div>
<div class="fld"><label>Display picture</label></div>
<div class="avatar-grid" id="avatarGrid"></div>
<div class="fld"><label>About Me — everything your attorney should know (your story, dates, names, what you're going through)</label><textarea id="pfAbout" placeholder="e.g. Married 2011, two children (ages 9 and 12), separated June 2025, own a home in King County, spouse filed a parenting plan…"></textarea></div>
<button class="btn primary" style="width:100%" onclick="saveProfile()">Save profile</button>
</div></div>
<!-- Settings modal -->
<div class="modal-bg" id="settingsModal"><div class="modal">
<button class="close" onclick="closeModal('settingsModal')">×</button>
<h2>Settings</h2><div class="sub">Comms channels your attorney can use for missions, and safety.</div>
<div class="fld"><label>Twilio Account SID</label><input id="twSid" placeholder="AC…"></div>
<div class="fld"><label>Twilio Auth Token</label><input id="twToken" type="password" placeholder="••••"></div>
<div class="fld"><label>Twilio From number (E.164)</label><input id="twFrom" placeholder="+14255550123"></div>
<div class="grid2">
<div class="fld"><label>SMTP host</label><input id="smHost" placeholder="smtp.gmail.com"></div>
<div class="fld"><label>SMTP port</label><input id="smPort" value="587"></div>
</div>
<div class="grid2">
<div class="fld"><label>SMTP username</label><input id="smUser"></div>
<div class="fld"><label>SMTP password</label><input id="smPass" type="password"></div>
</div>
<div class="fld"><label>Email "from" address</label><input id="emFrom" placeholder="you@example.com"></div>
<div class="fld"><label>TTS voice</label><select id="ttsVoice"></select></div>
<div class="fld switch"><label style="margin:0">🔒 No-contact order in effect</label><input type="checkbox" id="ncOrder"></div>
<div class="fld"><label>Protected person's name (blocked from contact)</label><input id="ncName" placeholder="Full name of the person you must not contact"></div>
<div class="fld switch"><label style="margin:0">⚠️ Domestic violence is a factor in my case</label><input type="checkbox" id="dvFactor"></div>
<div class="fld switch"><label style="margin:0">🛡️ Active protection order in effect (RCW 7.105)</label><input type="checkbox" id="poOrder"></div>
<div class="fld"><label>Safety notes — threats, incidents, what you're afraid of</label><textarea id="safetyNotes" style="min-height:70px" placeholder="e.g. There has been physical violence. I'm afraid for my safety and the children's safety."></textarea></div>
<button class="btn primary" style="width:100%" onclick="saveSettings()">Save settings</button>
</div></div>
<!-- Documents modal -->
<div class="modal-bg" id="docsModal"><div class="modal">
<button class="close" onclick="closeModal('docsModal')">×</button>
<h2>Prepare a document</h2><div class="sub">Court-ready Washington divorce paperwork, drafted to your facts.</div>
<div class="fld"><label>Document</label><select id="docType"></select></div>
<div class="fld"><label>Specific instructions (optional)</label><textarea id="docInst" placeholder="e.g. I want primary residential time with the children, propose alternate weekends for the other parent."></textarea></div>
<button class="btn primary" style="width:100%" id="docBtn" onclick="genDoc()">Generate document</button>
<div class="doc-result" id="docResult" style="display:none"></div>
</div></div>
<!-- Mission modal -->
<div class="modal-bg" id="missionModal"><div class="modal">
<button class="close" onclick="closeModal('missionModal')">×</button>
<h2>Send on your behalf</h2><div class="sub">Your attorney will format this professionally. A no-contact order blocks protected recipients.</div>
<div class="fld"><label>Type</label><select id="msType"><option value="sms">Text (SMS)</option><option value="email">Email</option><option value="call">Phone call</option></select></div>
<div class="fld"><label>Recipient (phone E.164 or email)</label><input id="msRecipient" placeholder="+14255550123 or name@example.com"></div>
<div class="fld" id="msSubjectWrap" style="display:none"><label>Subject</label><input id="msSubject"></div>
<div class="fld"><label>Message</label><textarea id="msBody" placeholder="What do you want to say?"></textarea></div>
<button class="btn primary" style="width:100%" onclick="doMission()">Send</button>
<div id="msStatus" style="margin-top:12px;font-size:13px"></div>
</div></div>
<script> <script>
const AGENTS = {{ agents | tojson }}; const AGENTS = {{ agents | tojson }};
const DISCLAIMER = {{ disclaimer | tojson }}; const token = localStorage.getItem('astraea_token') || '';
// ---- 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; let active = AGENTS[0].id;
const history = {}; // per-agent turn history let avatars = [];
let myProfile = {display_name:'', avatar:0, about_me:''};
let mySettings = {};
const history = {};
AGENTS.forEach(a => history[a.id] = []); AGENTS.forEach(a => history[a.id] = []);
// starfield
const cvs = document.getElementById('stars'), ctx = cvs.getContext('2d');
let stars = [];
(function size(){ cvs.width=innerWidth; cvs.height=innerHeight; stars=Array.from({length:120},()=>({x:Math.random()*cvs.width,y:Math.random()*cvs.height,r:Math.random()*1.3+.3,t:Math.random()*6.28,s:Math.random()*.02+.005})); })();
addEventListener('resize',()=>{cvs.width=innerWidth;cvs.height=innerHeight;});
(function draw(){ ctx.clearRect(0,0,cvs.width,cvs.height); for(const s of stars){ s.t+=s.s; const a=.3+Math.abs(Math.sin(s.t))*.7; ctx.beginPath(); ctx.arc(s.x,s.y,s.r,0,6.28); ctx.fillStyle=`rgba(190,210,255,${a})`; ctx.fill(); } requestAnimationFrame(draw); })();
function authHeaders(){ return token ? {'Authorization':'Bearer '+token} : {}; }
function accent(id){ const a=AGENTS.find(x=>x.id===id); return a?a.accent:'#7dd3fc'; } function accent(id){ const a=AGENTS.find(x=>x.id===id); return a?a.accent:'#7dd3fc'; }
function closeModal(id){ document.getElementById(id).classList.remove('open'); }
function renderGrid(){ function renderGrid(){
grid.innerHTML = ''; const g=document.getElementById('grid'); g.innerHTML='';
AGENTS.forEach(a => { 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); g.appendChild(c); });
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(){ function renderChips(){
chips.innerHTML = ''; const c=document.getElementById('chips'); c.innerHTML='';
AGENTS.forEach(a => { 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); c.appendChild(b); });
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);
});
} }
async function selectAgent(id,scroll){
function selectAgent(id, scroll) { active=id; const a=AGENTS.find(x=>x.id===id);
active = id;
const a = AGENTS.find(x => x.id === id);
document.getElementById('headEmoji').textContent=a.emoji; document.getElementById('headEmoji').textContent=a.emoji;
document.getElementById('headName').textContent=a.name; document.getElementById('headName').textContent=a.name;
document.getElementById('headScope').textContent=a.tagline; document.getElementById('headScope').textContent=a.tagline;
document.getElementById('chatPanel').style.setProperty('--c', a.accent); const cp=document.getElementById('chatPanel'); cp.style.setProperty('--c',a.accent); cp.style.borderColor=a.accent+'55';
document.getElementById('chatPanel').style.borderColor = a.accent + '55';
renderGrid(); renderChips(); renderGrid(); renderChips();
messages.innerHTML = ''; const m=document.getElementById('messages'); m.innerHTML='';
renderHistory(); if(token && !(history[id]||[]).length){
if (scroll) document.getElementById('chatPanel').scrollIntoView({behavior: 'smooth', block: 'start'}); try{ const r=await fetch('/api/conversation/'+id,{headers:authHeaders()}); const d=await r.json(); history[id]=d.history||[]; }catch(e){}
input.focus();
} }
(history[id]||[]).forEach(t=>addBubble(t.role,t.content,t.citations));
function renderHistory() { if(scroll) cp.scrollIntoView({behavior:'smooth',block:'start'});
const h = history[active] || []; }
h.forEach(t => addBubble(t.role, t.content, t.citations)); async function clearConv(){
if(token){ try{ await fetch('/api/conversation/'+active,{method:'DELETE',headers:authHeaders()}); }catch(e){} }
history[active]=[]; document.getElementById('messages').innerHTML='';
}
let rec=null;
function toggleMic(){
const SR=window.SpeechRecognition||window.webkitSpeechRecognition;
const mb=document.getElementById('micBtn');
if(!SR){ mb.title='Voice input needs Chrome/Edge'; return; }
if(rec){ rec.stop(); rec=null; mb.classList.remove('on'); mb.style.borderColor=''; return; }
rec=new SR(); rec.lang='en-US'; rec.interimResults=false;
rec.onresult=e=>{ const t=e.results[0][0].transcript; const i=document.getElementById('input'); i.value=(i.value?i.value+' ':'')+t; };
rec.onend=()=>{ rec=null; mb.classList.remove('on'); mb.style.borderColor=''; };
rec.onerror=()=>{ rec=null; mb.classList.remove('on'); mb.style.borderColor=''; };
rec.start(); mb.classList.add('on'); mb.style.borderColor='#f87171';
} }
function addBubble(role,text,citations){ function addBubble(role,text,citations){
const m = document.createElement('div'); const m=document.createElement('div'); m.className='msg '+role;
m.className = 'msg ' + role; const l=document.createElement('div'); l.className='who-label'; l.textContent=role==='user'?'You':(AGENTS.find(x=>x.id===active)?.name||'Attorney');
const label = document.createElement('div'); const b=document.createElement('div'); b.className='bubble'; b.textContent=text;
label.className = 'who-label'; m.appendChild(l); m.appendChild(b);
label.textContent = role === 'user' ? 'You' : (AGENTS.find(x => x.id === active)?.name || 'Agent'); if(role==='assistant'){
const b = document.createElement('div'); const sp=document.createElement('button'); sp.className='speak'; sp.textContent='🔊 Listen'; sp.onclick=()=>speak(text); m.appendChild(sp);
b.className = 'bubble'; if(citations&&citations.length){ const cw=document.createElement('div'); cw.className='citations'; const t=document.createElement('button'); t.className='cite-toggle'; t.textContent='🔗 '+citations.length+' source'+(citations.length>1?'s':''); const cl=document.createElement('div'); cl.className='cite-list'; citations.forEach(c=>{ const it=document.createElement('div'); it.className='cite'; it.innerHTML=`<b>${c.title}</b>${c.snippet}<div style="color:var(--gold);font-size:10.5px">${c.source}</div>`; cl.appendChild(it); }); t.onclick=()=>cl.classList.toggle('open'); cw.appendChild(t); cw.appendChild(cl); m.appendChild(cw); }
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); document.getElementById('messages').appendChild(m);
messages.scrollTop = messages.scrollHeight; document.getElementById('messages').scrollTop=document.getElementById('messages').scrollHeight;
} }
function typing(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); document.getElementById('messages').appendChild(m); }
function typingIndicator(on) { async function speak(text){
document.querySelectorAll('.typing').forEach(e => e.remove()); try{ const r=await fetch('/api/speak',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:text.replace(/\n+/g,'. ').substring(0,2500),voice:mySettings.tts_voice||'aria'})}); const b=await r.blob(); new Audio(URL.createObjectURL(b)).play(); }catch(e){}
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(){ async function send(){
const text = input.value.trim(); const t=document.getElementById('input').value.trim(); if(!t)return;
if (!text) return; document.getElementById('input').value='';
input.value = ''; input.style.height = 'auto'; addBubble('user',t); history[active].push({role:'user',content:t});
addBubble('user', text); typing(true); document.getElementById('sendBtn').disabled=true;
history[active].push({role: 'user', content: text});
typingIndicator(true); sendBtn.disabled = true;
try{ try{
const r = await fetch('/api/chat', { const r=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify({agent_id:active,message:t,history:history[active].slice(0,-1)})});
method: 'POST', headers: {'Content-Type': 'application/json'}, const d=await r.json(); typing(false);
body: JSON.stringify({agent_id: active, message: text, history: history[active].slice(0, -1)}) 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.',null);
const d = await r.json(); }catch(e){ typing(false); addBubble('assistant','Could not reach the engine.',null); }
typingIndicator(false); finally{ document.getElementById('sendBtn').disabled=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; async function loadAvatars(){ const r=await fetch('/api/avatars'); avatars=await r.json(); const g=document.getElementById('avatarGrid'); g.innerHTML=''; avatars.forEach(a=>{ const d=document.createElement('div'); d.className='avatar-opt'; d.style.background=`linear-gradient(135deg,${a.c1},${a.c2})`; d.textContent=a.glyph; d.title=a.label; d.onclick=()=>{ myProfile.avatar=a.id; renderAvatarSel(); }; g.appendChild(d); }); }
input.addEventListener('keydown', e => { function renderAvatarSel(){ document.querySelectorAll('.avatar-opt').forEach((d,i)=>d.classList.toggle('sel',i===myProfile.avatar)); }
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } function renderMyAvatar(){ const a=avatars[myProfile.avatar]||avatars[0]; const el=document.getElementById('myAvatar'); if(a){ el.style.background=`linear-gradient(135deg,${a.c1},${a.c2})`; el.textContent=a.glyph; } }
}); function openProfile(){ document.getElementById('pfName').value=myProfile.display_name||''; document.getElementById('pfAbout').value=myProfile.about_me||''; renderAvatarSel(); document.getElementById('profileModal').classList.add('open'); }
input.addEventListener('input', () => { async function saveProfile(){
input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 140) + 'px'; const body={display_name:document.getElementById('pfName').value, about_me:document.getElementById('pfAbout').value, avatar:myProfile.avatar};
}); const r=await fetch('/api/profile',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify(body)});
myProfile=await r.json(); renderMyAvatar(); closeModal('profileModal');
// ---- 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'; }); function openSettings(){
const s=mySettings; document.getElementById('twSid').value=s.twilio_sid||''; document.getElementById('twToken').value=''; document.getElementById('twFrom').value=s.twilio_from||''; document.getElementById('smHost').value=s.smtp_host||''; document.getElementById('smPort').value=s.smtp_port||587; document.getElementById('smUser').value=s.smtp_user||''; document.getElementById('emFrom').value=s.email_from||''; document.getElementById('ttsVoice').value=s.tts_voice||'aria'; document.getElementById('ncOrder').checked=!!s.no_contact_order; document.getElementById('ncName').value=s.partner_name||''; document.getElementById('dvFactor').checked=!!s.domestic_violence; document.getElementById('poOrder').checked=!!s.protection_order; document.getElementById('safetyNotes').value=s.safety_notes||'';
document.getElementById('settingsModal').classList.add('open');
}
async function saveSettings(){
const body={ twilio_sid:document.getElementById('twSid').value, twilio_from:document.getElementById('twFrom').value, smtp_host:document.getElementById('smHost').value, smtp_port:parseInt(document.getElementById('smPort').value||587), smtp_user:document.getElementById('smUser').value, email_from:document.getElementById('emFrom').value, tts_voice:document.getElementById('ttsVoice').value, no_contact_order:document.getElementById('ncOrder').checked?1:0, partner_name:document.getElementById('ncName').value, domestic_violence:document.getElementById('dvFactor').checked?1:0, protection_order:document.getElementById('poOrder').checked?1:0, safety_notes:document.getElementById('safetyNotes').value };
const tw=document.getElementById('twToken').value; if(tw) body.twilio_auth_token=tw;
const sp=document.getElementById('smPass').value; if(sp) body.smtp_pass=sp;
const r=await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify(body)});
mySettings=await r.json(); closeModal('settingsModal');
}
async function openDocs(){ const r=await fetch('/api/documents/types'); const types=await r.json(); const s=document.getElementById('docType'); s.innerHTML=''; types.forEach(t=>{ const o=document.createElement('option'); o.value=t.id; o.textContent=t.name+' ('+t.form+')'; s.appendChild(o); }); document.getElementById('docResult').style.display='none'; document.getElementById('docsModal').classList.add('open'); }
async function genDoc(){
document.getElementById('docBtn').disabled=true;
const body={doc_type:document.getElementById('docType').value, instructions:document.getElementById('docInst').value};
const r=await fetch('/api/documents',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify(body)});
const d=await r.json(); document.getElementById('docBtn').disabled=false;
const el=document.getElementById('docResult'); el.style.display='block'; el.textContent=d.document||d.error;
}
function openMission(){ document.getElementById('missionModal').classList.add('open'); }
document.getElementById('msType').onchange=function(){ document.getElementById('msSubjectWrap').style.display=this.value==='email'?'block':'none'; };
async function doMission(){
const type=document.getElementById('msType').value, recipient=document.getElementById('msRecipient').value;
const body={type,recipient,message:document.getElementById('msBody').value,subject:document.getElementById('msSubject').value,body:document.getElementById('msBody').value};
const r=await fetch('/api/mission',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify(body)});
const d=await r.json(); document.getElementById('msStatus').textContent=d.ok?'✓ Sent.':('✗ '+(d.error||'Failed')); document.getElementById('msStatus').style.color=d.ok?'#4ade80':'#fca5a5';
}
document.getElementById('sendBtn').onclick=send;
document.getElementById('input').addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();} });
async function boot(){
if(!token){ window.location.href='/'; return; }
try{ const r=await fetch('/api/me',{headers:authHeaders()}); if(!r.ok){ window.location.href='/'; return; } const d=await r.json(); myProfile=d.profile; mySettings=d.settings; }catch(e){}
await loadAvatars(); renderMyAvatar(); renderGrid(); renderChips(); selectAgent(active,false);
// populate tts voices
try{ const vr=await fetch('/api/voices'); const vd=await vr.json(); const s=document.getElementById('ttsVoice'); s.innerHTML=''; vd.voices.forEach(v=>{ const o=document.createElement('option'); o.value=v.id; o.textContent=v.label; s.appendChild(o); }); s.value=mySettings.tts_voice||'aria'; }catch(e){}
}
boot();
</script> </script>
<!-- Umami analytics beacon -->
<script async src="https://analytics.thetempleofdoom.com/script.js" data-website-id="c8c2f69c-4448-40ab-9848-02179f10c001"></script>
</body> </body>
</html> </html>

124
templates/landing.html Normal file
View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Astraea — Your Washington Divorce Attorney</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #05070f; color: #e8edfb; overflow-x: hidden; -webkit-font-smoothing: antialiased;
}
.hero {
position: relative; min-height: 100vh; display: flex; align-items: center;
background: radial-gradient(1200px 700px at 50% -10%, #16224a 0%, #070b1a 60%, #05070f 100%);
}
.hero-bg { position: absolute; inset: 0; overflow: hidden; }
.hero-bg img {
width: 100%; height: 100%; object-fit: cover; opacity: .55;
-webkit-mask-image: radial-gradient(ellipse at 50% 40%, #000 30%, transparent 75%);
mask-image: radial-gradient(ellipse at 50% 40%, #000 30%, transparent 75%);
}
.hero-overlay { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(5,7,15,.35), rgba(5,7,15,.9) 80%, #05070f); }
.wrap { position: relative; z-index: 2; width: 100%; max-width: 1180px; margin: 0 auto; padding: 40px 26px; }
header { display: flex; align-items: center; justify-content: space-between; }
.brand { display: flex; align-items: center; gap: 13px; }
.logo { width: 46px; height: 46px; border-radius: 13px; background: linear-gradient(135deg,#6ea8ff,#a78bfa); display: grid; place-items: center; font-size: 24px; box-shadow: 0 0 26px rgba(124,155,255,.55); }
.brand h1 { font-size: 24px; font-weight: 800; letter-spacing: .4px; }
.brand h1 span { color: #f5d06f; }
.pill { font-size: 12px; color: #aeb8d9; border: 1px solid #233055; padding: 6px 13px; border-radius: 999px; display: flex; align-items: center; gap: 7px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 10px #4ade80; }
.hero-content { display: grid; grid-template-columns: 1.25fr .75fr; gap: 50px; align-items: center; padding: 60px 0 40px; }
.left h2 { font-size: clamp(34px, 5vw, 60px); line-height: 1.05; font-weight: 800; letter-spacing: -1px; }
.left h2 .grad { background: linear-gradient(90deg,#7dd3fc,#a78bfa,#f0abfc); -webkit-background-clip: text; background-clip: text; color: transparent; }
.left .sub { color: #aeb8d9; font-size: 18px; line-height: 1.6; margin-top: 20px; max-width: 560px; }
.left .sub b { color: #e8edfb; }
.agents-row { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 26px; }
.agent-chip { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; padding: 8px 13px; border-radius: 999px; border: 1px solid #233055; background: rgba(16,24,50,.6); }
.cta-row { margin-top: 34px; display: flex; gap: 14px; flex-wrap: wrap; }
.btn { border: none; border-radius: 13px; padding: 15px 28px; font-size: 16px; font-weight: 700; cursor: pointer; transition: transform .12s ease, box-shadow .2s ease; }
.btn-primary { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; box-shadow: 0 10px 30px rgba(59,130,246,.4); }
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 16px 40px rgba(59,130,246,.55); }
.btn-ghost { background: rgba(16,24,50,.6); color: #e8edfb; border: 1px solid #233055; }
.card {
background: linear-gradient(180deg, rgba(20,29,61,.9), rgba(16,24,50,.85));
border: 1px solid #233055; border-radius: 20px; padding: 28px;
box-shadow: 0 30px 70px rgba(0,0,0,.5); backdrop-filter: blur(8px);
}
.card h3 { font-size: 20px; font-weight: 700; margin-bottom: 4px; }
.card .hint { color: #8b98c4; font-size: 13px; margin-bottom: 20px; }
.field { margin-bottom: 15px; }
.field label { display: block; font-size: 12.5px; color: #aeb8d9; margin-bottom: 6px; }
.field input { width: 100%; background: #0c142e; border: 1px solid #233055; color: #e8edfb; border-radius: 11px; padding: 12px 14px; font-size: 15px; outline: none; }
.field input:focus { border-color: #6366f1; }
.err { color: #fca5a5; font-size: 13px; margin-bottom: 12px; min-height: 18px; }
.tabs { display: flex; gap: 4px; margin-bottom: 20px; background: #0c142e; border-radius: 11px; padding: 4px; }
.tabs button { flex: 1; border: none; background: transparent; color: #8b98c4; padding: 10px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600; }
.tabs button.on { background: #1c2748; color: #e8edfb; }
footer { text-align: center; padding: 20px 0 40px; color: #5a658a; font-size: 12.5px; }
footer a { color: #8b98c4; }
@media (max-width: 860px) { .hero-content { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="hero">
<div class="hero-bg"><img src="/static/hero.png" alt="Astraea"></div>
<div class="hero-overlay"></div>
<div class="wrap">
<header>
<div class="brand"><div class="logo">⚖️</div><div><h1>ASTRA<span>EA</span></h1></div></div>
<div class="pill"><span class="dot"></span> Washington family law, on your side</div>
</header>
<div class="hero-content">
<div class="left">
<h2>Your personal<br><span class="grad">divorce attorney</span>,<br>reinvented.</h2>
<p class="sub">Astraea is a constellation of <b>eight Washington State legal specialists</b> — grounded in the real Revised Code of Washington and <b>verified case law</b> — that give you sound legal advice on the facts, prep your court paperwork on demand, and even handle professional correspondence for you.</p>
<div class="agents-row">
{% for a in agents %}<div class="agent-chip">{{ a.emoji }} {{ a.name.split(' & ')[0].split(' ')[0] }}</div>{% endfor %}
</div>
<div class="cta-row">
<button class="btn btn-primary" onclick="document.getElementById('auth').scrollIntoView({behavior:'smooth'})">Begin your case →</button>
<button class="btn btn-ghost" onclick="window.location.href='/app'">I have an account</button>
</div>
</div>
<div class="card" id="auth">
<h3>Welcome back</h3>
<div class="hint">No email, no verification. Just you and your counsel.</div>
<div class="tabs">
<button id="tabLogin" class="on" onclick="setTab('login')">Sign in</button>
<button id="tabRegister" onclick="setTab('register')">Create account</button>
</div>
<div class="err" id="err"></div>
<div class="field"><label>Username</label><input id="u" autocomplete="username"></div>
<div class="field"><label>Password</label><input id="p" type="password" autocomplete="current-password"></div>
<button class="btn btn-primary" style="width:100%" id="go">Sign in</button>
</div>
</div>
<footer>
Astraea provides practical legal guidance grounded in Washington statute and case law — not a substitute for a licensed attorney.
<br><a href="https://buymeacoffee.com/r26xrthzttg" target="_blank" rel="noopener">☕ Support Astraea</a>
</footer>
</div>
</div>
<script>
let mode = 'login';
function setTab(m){ mode = m; document.getElementById('tabLogin').classList.toggle('on', m==='login'); document.getElementById('tabRegister').classList.toggle('on', m==='register'); document.getElementById('go').textContent = m==='login' ? 'Sign in' : 'Create account'; }
async function go(){
const u = document.getElementById('u').value.trim(), p = document.getElementById('p').value;
const err = document.getElementById('err');
if(!u || !p){ err.textContent = 'Enter a username and password.'; return; }
const r = await fetch('/api/'+mode, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({username:u, password:p})});
const d = await r.json();
if(!r.ok){ err.textContent = d.error || 'Something went wrong'; return; }
localStorage.setItem('astraea_token', d.token);
localStorage.setItem('astraea_user', d.username);
window.location.href = '/app';
}
document.getElementById('go').onclick = go;
document.getElementById('p').addEventListener('keydown', e => { if(e.key==='Enter') go(); });
</script>
</body>
</html>

33
tts.py Normal file
View File

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
"""Astraea TTS — realistic neural voices via edge-tts (Microsoft neural, sounds human).
Structured so a local GPU TTS (Kokoro/XTTS on the Windows box) can be swapped in later.
"""
import os
import subprocess
import sys
import tempfile
# Resolve edge-tts relative to the venv that runs this app (robust vs hardcoded paths).
EDGE_TTS_BIN = os.path.join(os.path.dirname(sys.executable), "edge-tts")
VOICES = {
"aria": {"name": "en-US-AriaNeural", "label": "Aria — confident (F)"},
"jenny": {"name": "en-US-JennyNeural", "label": "Jenny — warm (F)"},
"ana": {"name": "en-US-AnaNeural", "label": "Ana — calm (F)"},
"guy": {"name": "en-US-GuyNeural", "label": "Guy — professional (M)"},
"christopher": {"name": "en-US-ChristopherNeural", "label": "Christopher — deep (M)"},
"eric": {"name": "en-US-EricNeural", "label": "Eric — measured (M)"},
}
def synthesize(text, voice="aria", rate="-5%"):
v = VOICES.get(voice, VOICES["aria"])["name"]
text = text.replace("\n", " ").replace(" ", " ")[:4000]
out = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False).name
subprocess.run([EDGE_TTS_BIN, "--voice", v, f"--rate={rate}", "--text", text,
"--write-media", out], capture_output=True, timeout=60, check=True)
return out
def list_voices():
return [{"id": k, "label": v["label"]} for k, v in VOICES.items()]