diff --git a/agents.py b/agents.py index 990a340..7c83b1d 100644 --- a/agents.py +++ b/agents.py @@ -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. OLLAMA_URL = "http://10.30.20.29:11434" EMBED_MODEL = "nomic-embed-text-v2-moe:latest" # semantic embeddings (verified on nightmare) -RAG_MODEL = "granite4.2:latest" # grounded-answer RAG lane (num_ctx <= 16384) -GENERAL_MODEL = "qwen3.8fast:latest" # fallback / general reasoning +RAG_MODEL = "ornith-1.5:9b" # verified best legal-advice answer lane (benchmark Sept 2026) +GENERAL_MODEL = "granite4.2:latest" # fallback (reliable, grounded) BOOKS_DIR = "/opt/astraea/books" @@ -19,7 +19,7 @@ AGENTS = [ "emoji": "\U0001F9ED", "tagline": "Where to start", "description": "Point you at the right specialist and walk through the Washington family-law process, from residency to final decree.", - "books": ["overview-intake.md", "mediation-adr.md"], + "books": ["overview-intake.md", "mediation-adr.md", "adoption.md"], "accent": "#7dd3fc", "system": ( "You are the Navigator, the intake specialist for Astraea, a Washington State " @@ -36,14 +36,17 @@ AGENTS = [ "emoji": "\u2696\uFE0F", "tagline": "Ending a marriage", "description": "No-fault dissolution, legal separation, the 90-day wait, grounds, residency, and the decree.", - "books": ["divorce-dissolution.md"], + "books": ["divorce-dissolution.md", "divorce-case-law-characterization.md", + "divorce-case-law-division.md", "divorce-case-law-maintenance.md"], "accent": "#a5b4fc", "system": ( "You are the Divorce & Dissolution specialist for Astraea, a Washington State " "family-law assistant. Washington is a no-fault divorce state. Explain dissolution of " "marriage and legal separation under RCW 26.09: residency requirements, the petition, " "service, the 90-day waiting period, temporary orders, default, and the final decree. " - "Be clear about what 'no-fault' means and what the court can and cannot decide." + "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", "tagline": "Parenting plans", "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", "system": ( "You are the Child Custody & Parenting Plans specialist for Astraea, a Washington " "State family-law assistant. Explain parenting plans and residential schedules under " "RCW 26.09.184-.191: the best-interest-of-the-child factors, restrictions under " "RCW 26.09.191 (domestic violence, abuse, etc.), decision-making, relocation, " - "and nonparental custody actions under RCW 26.10. Always center the child's safety " - "and best interests." + "and nonparental custody. Note: nonparental custody is now minor guardianship under " + "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", "tagline": "The support schedule", "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", "system": ( "You are the Child Support specialist for Astraea, a Washington State family-law " @@ -85,7 +89,7 @@ AGENTS = [ "emoji": "\U0001F54A\uFE0F", "tagline": "Alimony in WA", "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", "system": ( "You are the Spousal Maintenance (alimony) specialist for Astraea, a Washington State " @@ -101,7 +105,7 @@ AGENTS = [ "emoji": "\U0001F3E0", "tagline": "Community property", "description": "Separate vs. community property, characterization, division, pensions, and marital debt.", - "books": ["property-debt.md"], + "books": ["property-debt.md", "committed-relationships.md"], "accent": "#fdba74", "system": ( "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} -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." diff --git a/app.py b/app.py index 0138b9d..c511812 100644 --- a/app.py +++ b/app.py @@ -1,67 +1,142 @@ # -*- coding: utf-8 -*- -"""Astraea — a constellation of Washington State family-law specialist agents. - -Flask app + semantic RAG backend. LLM (granite4.2 RAG lane) and embeddings -(nomic-embed-text-v2-moe) run on the Ollama host (nightmare). +"""Astraea — the world's best Washington divorce-law attorney. +Multi-agent RAG + no-KYC profiles (About Me) + document prep + comms missions + TTS. """ import json import re import time import urllib.request -from flask import Flask, jsonify, render_template, request +from flask import Flask, jsonify, render_template, request, send_file, Response import rag -from agents import ( - AGENTS, AGENT_BY_ID, OLLAMA_URL, RAG_MODEL, GENERAL_MODEL, DISCLAIMER, -) +import store +import comms +import documents +import tts +from agents import AGENTS, AGENT_BY_ID, OLLAMA_URL, RAG_MODEL, GENERAL_MODEL app = Flask(__name__) - -def _ollama_chat(model, messages, num_ctx=16384, num_predict=1024, temperature=0.1, timeout=180): - payload = { - "model": model, - "messages": messages, - "stream": False, - "think": False, - "options": { - "temperature": temperature, - "num_predict": num_predict, - "num_ctx": num_ctx, - }, - } - req = urllib.request.Request( - f"{OLLAMA_URL}/api/chat", - data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"}, - ) - opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) - with opener.open(req, timeout=timeout) as r: - d = json.loads(r.read().decode("utf-8")) - msg = d.get("message", {}) - content = msg.get("content") or msg.get("thinking") or "" - return content.strip() +# ── Professional avatars (gradient + glyph, rendered client-side) ── +AVATARS = [ + {"id": 0, "glyph": "\u2696", "c1": "#6ea8ff", "c2": "#a78bfa", "label": "Scales"}, + {"id": 1, "glyph": "\U0001F3DB", "c1": "#7dd3fc", "c2": "#0ea5e9", "label": "Court"}, + {"id": 2, "glyph": "\U0001F6E1", "c1": "#4ade80", "c2": "#059669", "label": "Shield"}, + {"id": 3, "glyph": "\U0001F4DC", "c1": "#f5d06f", "c2": "#d97706", "label": "Scroll"}, + {"id": 4, "glyph": "\U0001F54A", "c1": "#f0abfc", "c2": "#c026d3", "label": "Dove"}, + {"id": 5, "glyph": "\U0001F4BC", "c1": "#fdba74", "c2": "#ea580c", "label": "Briefcase"}, + {"id": 6, "glyph": "\U0001F512", "c1": "#67e8f9", "c2": "#0891b2", "label": "Key"}, + {"id": 7, "glyph": "\u2731", "c1": "#fda4af", "c2": "#e11d48", "label": "Star"}, + {"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"}, + {"id": 11, "glyph": "\U0001F9ED", "c1": "#5eead4", "c2": "#0d9488", "label": "Compass"}, +] -def _strip_model_sources(text): - """Remove a model-written trailing 'Sources:'/'References:' block (defensive).""" +# ── Ollama helpers ── +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?\s*$", text, flags=re.I)[0] return text.strip() -def _build_answer(agent, message, history): - top = rag.retrieve(message, agent["id"], top_k=5) - if not top: - return { - "answer": "I couldn't find relevant Washington law on that in my reference " - "library yet. Try rephrasing, or ask the Navigator to point you to " - "the right specialist.", - "citations": [], - "grounded": False, - } +# ── About Me injection (designated plainly, used silently) ── +def about_me_block(about_me): + if not about_me or not about_me.strip(): + return "" + return ( + "\n\n=== ABOUT THE USER (PRIVATE CONTEXT) ===\n" + f"{about_me.strip()}\n" + "=== END ABOUT THE USER ===\n" + "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 = [] citations = [] seen = set() @@ -71,32 +146,27 @@ def _build_answer(agent, message, history): continue seen.add(key) context_blocks.append(f"[{i}] ({c['source']} — {c['title']})\n{c['text']}") - citations.append({ - "source": c["source"], - "title": c["title"], - "score": round(score, 3), - "snippet": c["text"][:260] + ("…" if len(c["text"]) > 260 else ""), - }) + citations.append({"source": c["source"], "title": c["title"], + "score": round(score, 3), "snippet": c["text"][:260]}) system = ( f"{agent['system']}\n\n" "Rules:\n" - "- Answer the user's question DIRECTLY and concisely. Begin your answer immediately — " - "do NOT restate the question, do NOT narrate your reasoning, and do NOT say what you " - "are about to do.\n" - "- Answer using ONLY the reference documents provided below.\n" - "- Cite the RCW section (or source) inline for every legal claim, e.g. (RCW 26.09.030).\n" - "- If the documents do not contain the answer, say so clearly and suggest which " - "specialist or official resource to consult.\n" - "- Be practical and plain-English, specific to Washington State.\n" - "- Do NOT write a 'Sources' list at the end; cite inline only." - ) - user = ( - "REFERENCE DOCUMENTS:\n\n" - + "\n\n".join(context_blocks) - + f"\n\nUSER QUESTION: {message}" + "- Give REAL, practical legal advice: state the rule, apply it to the user's facts, " + "tell them what to do next.\n" + "- Answer DIRECTLY. Do not restate the question or narrate your reasoning.\n" + "- Ground every legal claim in the reference documents. Cite the RCW section AND the " + "controlling case law by name.\n" + "- Be plain-English, specific to Washington State.\n" + "- You may use the available tools (send_sms / send_email / make_call) ONLY when the " + "user explicitly asks you to contact someone on their behalf.\n" + + about_me_block(about_me) + + safety_block(settings) ) + user = ("REFERENCE DOCUMENTS:\n\n" + "\n\n".join(context_blocks) + + f"\n\nUSER QUESTION: {message}") + messages = [{"role": "system", "content": system}] if history: for turn in history[-6:]: @@ -105,66 +175,263 @@ def _build_answer(agent, message, history): messages.append({"role": "user", "content": user}) try: - answer = _ollama_chat(RAG_MODEL, messages) - except Exception as e: - # Fall back to the general model if the RAG lane is unavailable. + resp = _ollama_chat(RAG_MODEL, messages, tools=TOOLS) + except Exception: try: - answer = _ollama_chat(GENERAL_MODEL, messages, num_ctx=16384) + resp = _ollama_chat(GENERAL_MODEL, messages) except Exception: - return { - "answer": "The legal engine is temporarily unavailable. Please try again in a " - "moment. (Backend LLM could not be reached.)", - "citations": [], - "grounded": False, - } + return {"answer": "The legal engine is temporarily unavailable. Try again shortly.", + "citations": [], "grounded": False} - answer = _strip_model_sources(answer) - return { - "answer": answer, - "citations": citations, - "grounded": True, - "model": RAG_MODEL, - } + # tool-calling loop + for _ in range(3): + msg = resp.get("message", {}) + tool_calls = msg.get("tool_calls") or [] + if not tool_calls: + 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("/") -def index(): - agents_public = [ - {k: a[k] for k in ("id", "name", "emoji", "tagline", "description", "accent")} - for a in AGENTS - ] - return render_template("index.html", agents=agents_public, disclaimer=DISCLAIMER) +def landing(): + return render_template("landing.html", agents=AGENTS) -@app.route("/api/agents") -def api_agents(): - return jsonify([ - {k: a[k] for k in ("id", "name", "emoji", "tagline", "description", "accent")} - for a in AGENTS - ]) +@app.route("/app") +def app_page(): + return render_template("index.html", agents=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"]) def api_chat(): - data = request.get_json(force=True, silent=True) or {} - agent_id = (data.get("agent_id") or "navigator").strip() - message = (data.get("message") or "").strip() - history = data.get("history") or [] + uid = _uid() + d = request.get_json(force=True, silent=True) or {} + agent_id = (d.get("agent_id") or "navigator").strip() + message = (d.get("message") or "").strip() + client_history = d.get("history") or [] agent = AGENT_BY_ID.get(agent_id) if not agent: return jsonify({"error": "Unknown agent"}), 400 if not message: return jsonify({"error": "Message required"}), 400 - if len(message) > 4000: - message = message[:4000] - + 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() - 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["agent"] = agent_id return jsonify(result) +@app.route("/api/conversation/", 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 ) ── +@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'' + f'{msg}') + return Response(twiml, mimetype="text/xml") + + @app.route("/api/health") def health(): 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 -# Kick off the background index build once at import (before serving). +store.init_db() rag.start_background_index() - if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, threaded=True) diff --git a/benchmark_models.py b/benchmark_models.py new file mode 100644 index 0000000..66749e4 --- /dev/null +++ b/benchmark_models.py @@ -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() diff --git a/comms.py b/comms.py new file mode 100644 index 0000000..f9faa6a --- /dev/null +++ b/comms.py @@ -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() diff --git a/documents.py b/documents.py new file mode 100644 index 0000000..15acccd --- /dev/null +++ b/documents.py @@ -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() diff --git a/polish.py b/polish.py new file mode 100644 index 0000000..0d4314a --- /dev/null +++ b/polish.py @@ -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 ``.\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 to ), " + "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 ... block (with or without DOCTYPE) + m = re.search(r"(]*>.*?)", text, re.DOTALL | re.IGNORECASE) + if m: + html = m.group(1) + if not html.lower().lstrip().startswith("\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""): + problems.append(f"unbalanced <{tag}> tags") + # JS/HTML id cross-check + scripts = "\n".join(re.findall(r"]*>(.*?)", 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() diff --git a/static/hero.png b/static/hero.png new file mode 100644 index 0000000..a9b3ce1 Binary files /dev/null and b/static/hero.png differ diff --git a/store.py b/store.py new file mode 100644 index 0000000..1a25e63 --- /dev/null +++ b/store.py @@ -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) diff --git a/templates/index.html b/templates/index.html index 427cd82..ddf0fe7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,406 +3,333 @@ -Astraea — Washington Family Law, Decoded - +Astraea — Your Attorney -
✦
- -
+
-
- -
-

ASTRAEA

-
Washington Family Law, decoded by specialist agents
-
+

ASTRAEA

+
+ + +
⚖
-
Engines online
-
-

Eight specialists.
One constellation of Washington family law.

-

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.

-
+
+

Eight specialists. Your case.

+

Ask anything. Every answer is grounded in Washington statute and verified case law — and it already knows your situation.

+
- +
Choose your specialist
🧭
-
-
Navigator
-
Where to start
-
+
Navigator
Where to start
+
+ + + +
- + +
-
Answers cite the Revised Code of Washington. This is legal information, not advice.
-
{{ disclaimer }}
- +
Astraea gives practical legal guidance grounded in Washington law — not a substitute for a licensed attorney. · ☕ Support
+ + + + + + + + + + + + - - - diff --git a/templates/landing.html b/templates/landing.html new file mode 100644 index 0000000..8243d60 --- /dev/null +++ b/templates/landing.html @@ -0,0 +1,124 @@ + + + + + +Astraea — Your Washington Divorce Attorney + + + +
+
Astraea
+
+
+
+

ASTRAEA

+
Washington family law, on your side
+
+
+
+

Your personal
divorce attorney,
reinvented.

+

Astraea is a constellation of eight Washington State legal specialists — grounded in the real Revised Code of Washington and verified case law — that give you sound legal advice on the facts, prep your court paperwork on demand, and even handle professional correspondence for you.

+
+ {% for a in agents %}
{{ a.emoji }} {{ a.name.split(' & ')[0].split(' ')[0] }}
{% endfor %} +
+
+ + +
+
+
+

Welcome back

+
No email, no verification. Just you and your counsel.
+
+ + +
+
+
+
+ +
+
+
+ Astraea provides practical legal guidance grounded in Washington statute and case law — not a substitute for a licensed attorney. +
☕ Support Astraea +
+
+
+ + + diff --git a/tts.py b/tts.py new file mode 100644 index 0000000..4b79c3a --- /dev/null +++ b/tts.py @@ -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()]