Astraea v2: auth+profiles+About-Me, documents, TTS, Twilio/SMTP missions, DV safety, conversation memory, FLUX landing
This commit is contained in:
28
agents.py
28
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.
|
# 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,14 +36,17 @@ 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 "
|
||||||
"family-law assistant. Washington is a no-fault divorce state. Explain dissolution of "
|
"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, "
|
"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
474
app.py
@@ -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
92
benchmark_models.py
Normal 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
96
comms.py
Normal 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
139
documents.py
Normal 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
211
polish.py
Normal 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
BIN
static/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
205
store.py
Normal file
205
store.py
Normal 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)
|
||||||
@@ -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;
|
* { box-sizing:border-box; margin:0; padding:0; }
|
||||||
--bg2: #0b1228;
|
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; }
|
||||||
--panel: #101832;
|
canvas { position:fixed; inset:0; z-index:0; pointer-events:none; }
|
||||||
--panel2: #141d3d;
|
.app { position:relative; z-index:1; max-width:1120px; margin:0 auto; padding:0 20px 60px; }
|
||||||
--line: #233055;
|
header { padding:20px 0; display:flex; align-items:center; justify-content:space-between; gap:14px; }
|
||||||
--text: #e8edfb;
|
.brand { display:flex; align-items:center; gap:11px; }
|
||||||
--muted: #8b98c4;
|
.logo { width:40px; height:40px; border-radius:11px; background:linear-gradient(135deg,#6ea8ff,#a78bfa); display:grid; place-items:center; font-size:20px; }
|
||||||
--accent: #7dd3fc;
|
.brand h1 { font-size:20px; font-weight:800; }
|
||||||
--gold: #f5d06f;
|
.brand h1 span { color:var(--gold); }
|
||||||
}
|
.top-actions { display:flex; align-items:center; gap:10px; }
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
.avatar-sm { width:36px; height:36px; border-radius:50%; display:grid; place-items:center; font-size:17px; color:#fff; cursor:pointer; }
|
||||||
html, body { height: 100%; }
|
.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; }
|
||||||
body {
|
.btn:hover { border-color:#3b82f6; }
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
.btn.primary { background:linear-gradient(135deg,#3b82f6,#6366f1); border:none; color:#fff; }
|
||||||
background: radial-gradient(1200px 700px at 50% -10%, #18254f 0%, var(--bg2) 45%, var(--bg) 100%);
|
.hero { padding:12px 0 20px; }
|
||||||
color: var(--text);
|
.hero h2 { font-size:clamp(24px,4vw,34px); font-weight:800; letter-spacing:-.5px; }
|
||||||
overflow-x: hidden;
|
.hero p { color:var(--muted); margin-top:8px; font-size:15px; }
|
||||||
-webkit-font-smoothing: antialiased;
|
.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; }
|
||||||
#stars { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
|
.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; }
|
||||||
|
|
||||||
.wrap { position: relative; z-index: 1; max-width: 1120px; margin: 0 auto; padding: 0 22px 80px; }
|
.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; }
|
||||||
|
.chat-head { padding:14px 18px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:11px; flex-wrap:wrap; }
|
||||||
|
.chat-head .emoji { font-size:24px; }
|
||||||
|
.chat-head .who { font-weight:700; font-size:15px; }
|
||||||
|
.chat-head .scope { color:var(--muted); font-weight:500; font-size:12px; }
|
||||||
|
.chips { margin-left:auto; display:flex; gap:6px; flex-wrap:wrap; }
|
||||||
|
.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; }
|
||||||
|
.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; }
|
||||||
|
.msg.user { align-self:flex-end; align-items:flex-end; }
|
||||||
|
.msg.agent { align-self:flex-start; }
|
||||||
|
.bubble { padding:12px 15px; border-radius:15px; font-size:14.5px; line-height:1.6; white-space:pre-wrap; word-break:break-word; }
|
||||||
|
.msg.user .bubble { background:linear-gradient(135deg,#3b82f6,#6366f1); color:#fff; border-bottom-right-radius:4px; }
|
||||||
|
.msg.agent .bubble { background:var(--panel2); border:1px solid var(--line); border-bottom-left-radius:4px; }
|
||||||
|
.msg .who-label { font-size: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:9px; }
|
||||||
|
.cite-toggle { font-size:12px; color:var(--gold); cursor:pointer; background:none; border:none; }
|
||||||
|
.cite-list { margin-top:7px; display:none; flex-direction:column; gap:7px; }
|
||||||
|
.cite-list.open { display:flex; }
|
||||||
|
.cite { background:#0c142e; border:1px solid var(--line); border-radius:9px; padding:9px 11px; font-size:12px; color:var(--muted); }
|
||||||
|
.cite b { color:var(--text); display:block; margin-bottom:2px; }
|
||||||
|
.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; }
|
||||||
|
.typing i:nth-child(2){ animation-delay:.2s; } .typing i:nth-child(3){ animation-delay:.4s; }
|
||||||
|
@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; }
|
||||||
|
|
||||||
header {
|
.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; }
|
||||||
padding: 46px 0 8px;
|
.modal-bg.open { display:flex; }
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
.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; }
|
||||||
}
|
.modal h2 { font-size:20px; font-weight:800; margin-bottom:4px; }
|
||||||
.brand { display: flex; align-items: center; gap: 14px; }
|
.modal .sub { color:var(--muted); font-size:13px; margin-bottom:18px; }
|
||||||
.logo {
|
.modal .close { float:right; background:none; border:none; color:var(--muted); font-size:22px; cursor:pointer; }
|
||||||
width: 46px; height: 46px; border-radius: 13px;
|
.fld { margin-bottom:15px; }
|
||||||
background: linear-gradient(135deg, #6ea8ff, #a78bfa);
|
.fld label { display:block; font-size:12.5px; color:var(--muted); margin-bottom:6px; }
|
||||||
display: grid; place-items: center; font-size: 26px;
|
.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; }
|
||||||
box-shadow: 0 0 26px rgba(124, 155, 255, .55);
|
.fld textarea { min-height:110px; resize:vertical; }
|
||||||
}
|
.fld input:focus, .fld textarea:focus { border-color:#6366f1; }
|
||||||
.brand h1 { font-size: 26px; font-weight: 700; letter-spacing: .4px; }
|
.avatar-grid { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; margin-bottom:18px; }
|
||||||
.brand h1 span { color: var(--gold); }
|
.avatar-opt { aspect-ratio:1; border-radius:50%; display:grid; place-items:center; font-size:22px; color:#fff; cursor:pointer; border:2px solid transparent; }
|
||||||
.brand .sub { color: var(--muted); font-size: 13px; margin-top: 2px; letter-spacing: .3px; }
|
.avatar-opt.sel { border-color:#fff; box-shadow:0 0 0 3px rgba(255,255,255,.2); }
|
||||||
.pill {
|
.switch { display:flex; align-items:center; gap:10px; }
|
||||||
font-size: 12px; color: var(--muted); border: 1px solid var(--line);
|
.switch input { width:auto; }
|
||||||
padding: 6px 12px; border-radius: 999px; display: inline-flex; align-items: center; gap: 7px;
|
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:13px; }
|
||||||
}
|
.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; }
|
||||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 10px #4ade80; }
|
footer { text-align:center; margin-top:26px; font-size:12px; color:var(--muted); }
|
||||||
|
footer a { color:var(--muted); }
|
||||||
.hero { padding: 26px 0 34px; max-width: 720px; }
|
@media (max-width:640px){ .msg{max-width:92%;} .grid2{grid-template-columns:1fr;} .avatar-grid{grid-template-columns:repeat(4,1fr);} }
|
||||||
.hero h2 { font-size: clamp(28px, 5vw, 46px); line-height: 1.12; font-weight: 800; letter-spacing: -.5px; }
|
|
||||||
.hero h2 .grad {
|
|
||||||
background: linear-gradient(90deg, #7dd3fc, #a78bfa, #f0abfc);
|
|
||||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
|
||||||
}
|
|
||||||
.hero p { color: var(--muted); font-size: 16.5px; line-height: 1.6; margin-top: 14px; max-width: 640px; }
|
|
||||||
|
|
||||||
.section-label { font-size: 12px; text-transform: uppercase; letter-spacing: 2px; color: var(--muted); margin: 10px 0 16px; }
|
|
||||||
|
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
|
|
||||||
.card {
|
|
||||||
background: linear-gradient(180deg, var(--panel2), var(--panel));
|
|
||||||
border: 1px solid var(--line); border-radius: 16px; padding: 18px 18px 16px;
|
|
||||||
cursor: pointer; transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
|
|
||||||
position: relative; overflow: hidden;
|
|
||||||
}
|
|
||||||
.card::before {
|
|
||||||
content: ""; position: absolute; inset: 0 0 auto 0; height: 3px;
|
|
||||||
background: var(--c, var(--accent)); opacity: .85;
|
|
||||||
}
|
|
||||||
.card:hover { transform: translateY(-3px); border-color: var(--c, var(--accent)); box-shadow: 0 14px 34px rgba(0,0,0,.4); }
|
|
||||||
.card.active { border-color: var(--c, var(--accent)); box-shadow: 0 0 0 1px var(--c, var(--accent)), 0 16px 40px rgba(0,0,0,.5); }
|
|
||||||
.card .emoji { font-size: 30px; line-height: 1; }
|
|
||||||
.card h3 { font-size: 16.5px; font-weight: 700; margin: 12px 0 3px; }
|
|
||||||
.card .tag { font-size: 12px; color: var(--c, var(--accent)); font-weight: 600; margin-bottom: 8px; }
|
|
||||||
.card p { font-size: 13px; color: var(--muted); line-height: 1.5; }
|
|
||||||
|
|
||||||
.chat-panel {
|
|
||||||
margin-top: 38px;
|
|
||||||
background: linear-gradient(180deg, var(--panel2), var(--panel));
|
|
||||||
border: 1px solid var(--line); border-radius: 20px;
|
|
||||||
display: flex; flex-direction: column; overflow: hidden;
|
|
||||||
box-shadow: 0 30px 70px rgba(0,0,0,.45);
|
|
||||||
}
|
|
||||||
.chat-head {
|
|
||||||
padding: 16px 20px; border-bottom: 1px solid var(--line);
|
|
||||||
display: flex; align-items: center; gap: 13px;
|
|
||||||
}
|
|
||||||
.chat-head .emoji { font-size: 26px; }
|
|
||||||
.chat-head .who { font-weight: 700; font-size: 15.5px; }
|
|
||||||
.chat-head .who .scope { color: var(--muted); font-weight: 500; font-size: 12.5px; margin-top: 1px; }
|
|
||||||
.chips { margin-left: auto; display: flex; gap: 7px; flex-wrap: wrap; justify-content: flex-end; }
|
|
||||||
.chip {
|
|
||||||
font-size: 12px; padding: 6px 11px; border-radius: 999px; cursor: pointer;
|
|
||||||
border: 1px solid var(--line); color: var(--muted); background: transparent;
|
|
||||||
transition: all .15s ease; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.chip:hover { color: var(--text); border-color: var(--muted); }
|
|
||||||
.chip.on { color: #06121f; background: var(--c, var(--accent)); border-color: var(--c, var(--accent)); font-weight: 700; }
|
|
||||||
|
|
||||||
.messages { padding: 22px; min-height: 240px; max-height: 520px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
|
|
||||||
.msg { max-width: 78%; display: flex; flex-direction: column; }
|
|
||||||
.msg.user { align-self: flex-end; align-items: flex-end; }
|
|
||||||
.msg.agent { align-self: flex-start; align-items: flex-start; }
|
|
||||||
.bubble {
|
|
||||||
padding: 13px 16px; border-radius: 16px; font-size: 15px; line-height: 1.6; white-space: pre-wrap; word-break: break-word;
|
|
||||||
}
|
|
||||||
.msg.user .bubble { background: linear-gradient(135deg, #3b82f6, #6366f1); color: #fff; border-bottom-right-radius: 4px; }
|
|
||||||
.msg.agent .bubble { background: var(--panel2); border: 1px solid var(--line); border-bottom-left-radius: 4px; }
|
|
||||||
.msg .who-label { font-size: 11.5px; color: var(--muted); margin: 0 4px 5px; }
|
|
||||||
|
|
||||||
.citations { margin-top: 10px; width: 100%; }
|
|
||||||
.cite-toggle {
|
|
||||||
font-size: 12px; color: var(--gold); cursor: pointer; border: none; background: none;
|
|
||||||
display: inline-flex; align-items: center; gap: 5px; padding: 4px 2px;
|
|
||||||
}
|
|
||||||
.cite-list { margin-top: 8px; display: none; flex-direction: column; gap: 8px; }
|
|
||||||
.cite-list.open { display: flex; }
|
|
||||||
.cite {
|
|
||||||
background: #0c142e; border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px;
|
|
||||||
font-size: 12.5px; color: var(--muted);
|
|
||||||
}
|
|
||||||
.cite b { color: var(--text); display: block; margin-bottom: 3px; font-size: 13px; }
|
|
||||||
.cite .src { color: var(--gold); font-size: 11px; }
|
|
||||||
|
|
||||||
.typing { display: inline-flex; gap: 5px; align-items: center; padding: 6px 2px; }
|
|
||||||
.typing i { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); animation: blink 1.2s infinite; }
|
|
||||||
.typing i:nth-child(2) { animation-delay: .2s; }
|
|
||||||
.typing i:nth-child(3) { animation-delay: .4s; }
|
|
||||||
@keyframes blink { 0%,100% { opacity: .25; } 50% { opacity: 1; } }
|
|
||||||
|
|
||||||
.composer { border-top: 1px solid var(--line); padding: 14px 16px; display: flex; gap: 10px; align-items: flex-end; }
|
|
||||||
.composer textarea {
|
|
||||||
flex: 1; resize: none; background: #0c142e; border: 1px solid var(--line); color: var(--text);
|
|
||||||
border-radius: 12px; padding: 12px 14px; font-size: 15px; font-family: inherit; line-height: 1.5;
|
|
||||||
max-height: 140px; outline: none; transition: border-color .15s ease;
|
|
||||||
}
|
|
||||||
.composer textarea:focus { border-color: var(--c, var(--accent)); }
|
|
||||||
.send {
|
|
||||||
border: none; border-radius: 12px; padding: 13px 20px; font-size: 15px; font-weight: 700;
|
|
||||||
color: #06121f; background: var(--c, var(--accent)); cursor: pointer; transition: transform .1s ease, opacity .2s;
|
|
||||||
}
|
|
||||||
.send:active { transform: scale(.96); }
|
|
||||||
.send:disabled { opacity: .45; cursor: default; }
|
|
||||||
.hint { font-size: 11.5px; color: var(--muted); text-align: center; padding: 0 22px 14px; }
|
|
||||||
|
|
||||||
.disclaimer {
|
|
||||||
margin-top: 26px; font-size: 12.5px; color: var(--muted); text-align: center; line-height: 1.6;
|
|
||||||
border-top: 1px solid var(--line); padding-top: 20px;
|
|
||||||
}
|
|
||||||
footer { margin-top: 20px; text-align: center; }
|
|
||||||
.bmac {
|
|
||||||
display: inline-flex; align-items: center; gap: 9px; color: var(--text); text-decoration: none;
|
|
||||||
background: var(--panel2); border: 1px solid var(--line); padding: 10px 18px; border-radius: 999px;
|
|
||||||
font-size: 13.5px; font-weight: 600; transition: all .15s ease;
|
|
||||||
}
|
|
||||||
.bmac:hover { border-color: var(--gold); box-shadow: 0 0 18px rgba(245,208,111,.25); }
|
|
||||||
.bmac .cup { font-size: 17px; }
|
|
||||||
|
|
||||||
#mascot {
|
|
||||||
position: fixed; z-index: 2; pointer-events: none; will-change: transform;
|
|
||||||
font-size: 24px; filter: drop-shadow(0 0 8px rgba(255,220,150,.8)); opacity: .9;
|
|
||||||
}
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.chips { margin-left: 0; width: 100%; justify-content: flex-start; }
|
|
||||||
.chat-head { flex-wrap: wrap; }
|
|
||||||
.msg { max-width: 92%; }
|
|
||||||
}
|
|
||||||
</style>
|
</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] = []);
|
||||||
|
|
||||||
function accent(id) { const a = AGENTS.find(x => x.id === id); return a ? a.accent : '#7dd3fc'; }
|
// 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 renderGrid() {
|
function authHeaders(){ return token ? {'Authorization':'Bearer '+token} : {}; }
|
||||||
grid.innerHTML = '';
|
function accent(id){ const a=AGENTS.find(x=>x.id===id); return a?a.accent:'#7dd3fc'; }
|
||||||
AGENTS.forEach(a => {
|
function closeModal(id){ document.getElementById(id).classList.remove('open'); }
|
||||||
const c = document.createElement('div');
|
|
||||||
c.className = 'card' + (a.id === active ? ' active' : '');
|
function renderGrid(){
|
||||||
c.style.setProperty('--c', a.accent);
|
const g=document.getElementById('grid'); g.innerHTML='';
|
||||||
c.innerHTML = `<div class="emoji">${a.emoji}</div><h3>${a.name}</h3>
|
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); });
|
||||||
<div class="tag">${a.tagline}</div><p>${a.description}</p>`;
|
|
||||||
c.onclick = () => selectAgent(a.id, true);
|
|
||||||
grid.appendChild(c);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
function renderChips(){
|
||||||
function renderChips() {
|
const c=document.getElementById('chips'); c.innerHTML='';
|
||||||
chips.innerHTML = '';
|
AGENTS.forEach(a=>{ const b=document.createElement('button'); b.className='chip'+(a.id===active?' on':''); b.style.setProperty('--c',a.accent); b.textContent=a.emoji+' '+a.name.split(' & ')[0].split(' ')[0]; b.onclick=()=>selectAgent(a.id,false); c.appendChild(b); });
|
||||||
AGENTS.forEach(a => {
|
|
||||||
const b = document.createElement('button');
|
|
||||||
b.className = 'chip' + (a.id === active ? ' on' : '');
|
|
||||||
b.style.setProperty('--c', a.accent);
|
|
||||||
b.textContent = a.emoji + ' ' + a.name.split(' & ')[0].split(' ')[0];
|
|
||||||
b.onclick = () => selectAgent(a.id, false);
|
|
||||||
chips.appendChild(b);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
async function selectAgent(id,scroll){
|
||||||
function selectAgent(id, scroll) {
|
active=id; const a=AGENTS.find(x=>x.id===id);
|
||||||
active = id;
|
document.getElementById('headEmoji').textContent=a.emoji;
|
||||||
const a = AGENTS.find(x => x.id === id);
|
document.getElementById('headName').textContent=a.name;
|
||||||
document.getElementById('headEmoji').textContent = a.emoji;
|
document.getElementById('headScope').textContent=a.tagline;
|
||||||
document.getElementById('headName').textContent = a.name;
|
const cp=document.getElementById('chatPanel'); cp.style.setProperty('--c',a.accent); cp.style.borderColor=a.accent+'55';
|
||||||
document.getElementById('headScope').textContent = a.tagline;
|
|
||||||
document.getElementById('chatPanel').style.setProperty('--c', a.accent);
|
|
||||||
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));
|
||||||
|
if(scroll) cp.scrollIntoView({behavior:'smooth',block:'start'});
|
||||||
|
}
|
||||||
|
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){
|
||||||
|
const m=document.createElement('div'); 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 b=document.createElement('div'); b.className='bubble'; b.textContent=text;
|
||||||
|
m.appendChild(l); m.appendChild(b);
|
||||||
|
if(role==='assistant'){
|
||||||
|
const sp=document.createElement('button'); sp.className='speak'; sp.textContent='🔊 Listen'; sp.onclick=()=>speak(text); m.appendChild(sp);
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
document.getElementById('messages').appendChild(m);
|
||||||
|
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); }
|
||||||
|
|
||||||
|
async function speak(text){
|
||||||
|
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){}
|
||||||
|
}
|
||||||
|
async function send(){
|
||||||
|
const t=document.getElementById('input').value.trim(); if(!t)return;
|
||||||
|
document.getElementById('input').value='';
|
||||||
|
addBubble('user',t); history[active].push({role:'user',content:t});
|
||||||
|
typing(true); document.getElementById('sendBtn').disabled=true;
|
||||||
|
try{
|
||||||
|
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)})});
|
||||||
|
const d=await r.json(); typing(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.',null);
|
||||||
|
}catch(e){ typing(false); addBubble('assistant','Could not reach the engine.',null); }
|
||||||
|
finally{ document.getElementById('sendBtn').disabled=false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHistory() {
|
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); }); }
|
||||||
const h = history[active] || [];
|
function renderAvatarSel(){ document.querySelectorAll('.avatar-opt').forEach((d,i)=>d.classList.toggle('sel',i===myProfile.avatar)); }
|
||||||
h.forEach(t => addBubble(t.role, t.content, t.citations));
|
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'); }
|
||||||
|
async function saveProfile(){
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
function addBubble(role, text, citations) {
|
document.getElementById('sendBtn').onclick=send;
|
||||||
const m = document.createElement('div');
|
document.getElementById('input').addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();} });
|
||||||
m.className = 'msg ' + role;
|
|
||||||
const label = document.createElement('div');
|
|
||||||
label.className = 'who-label';
|
|
||||||
label.textContent = role === 'user' ? 'You' : (AGENTS.find(x => x.id === active)?.name || 'Agent');
|
|
||||||
const b = document.createElement('div');
|
|
||||||
b.className = 'bubble';
|
|
||||||
b.textContent = text;
|
|
||||||
m.appendChild(label); m.appendChild(b);
|
|
||||||
if (role === 'assistant' && citations && citations.length) {
|
|
||||||
const cw = document.createElement('div'); cw.className = 'citations';
|
|
||||||
const t = document.createElement('button'); t.className = 'cite-toggle';
|
|
||||||
t.innerHTML = '🔗 ' + citations.length + ' source' + (citations.length > 1 ? 's' : '');
|
|
||||||
const list = document.createElement('div'); list.className = 'cite-list';
|
|
||||||
citations.forEach(c => {
|
|
||||||
const item = document.createElement('div'); item.className = 'cite';
|
|
||||||
item.innerHTML = `<b>${c.title}</b><div>${c.snippet}</div><div class="src">${c.source}</div>`;
|
|
||||||
list.appendChild(item);
|
|
||||||
});
|
|
||||||
t.onclick = () => list.classList.toggle('open');
|
|
||||||
cw.appendChild(t); cw.appendChild(list);
|
|
||||||
m.appendChild(cw);
|
|
||||||
}
|
|
||||||
messages.appendChild(m);
|
|
||||||
messages.scrollTop = messages.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function typingIndicator(on) {
|
async function boot(){
|
||||||
document.querySelectorAll('.typing').forEach(e => e.remove());
|
if(!token){ window.location.href='/'; return; }
|
||||||
if (!on) 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){}
|
||||||
const m = document.createElement('div'); m.className = 'msg agent';
|
await loadAvatars(); renderMyAvatar(); renderGrid(); renderChips(); selectAgent(active,false);
|
||||||
const t = document.createElement('div'); t.className = 'typing';
|
// populate tts voices
|
||||||
t.innerHTML = '<i></i><i></i><i></i>';
|
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){}
|
||||||
m.appendChild(t); messages.appendChild(m);
|
|
||||||
messages.scrollTop = messages.scrollHeight;
|
|
||||||
}
|
}
|
||||||
|
boot();
|
||||||
async function send() {
|
|
||||||
const text = input.value.trim();
|
|
||||||
if (!text) return;
|
|
||||||
input.value = ''; input.style.height = 'auto';
|
|
||||||
addBubble('user', text);
|
|
||||||
history[active].push({role: 'user', content: text});
|
|
||||||
typingIndicator(true); sendBtn.disabled = true;
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/chat', {
|
|
||||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({agent_id: active, message: text, history: history[active].slice(0, -1)})
|
|
||||||
});
|
|
||||||
const d = await r.json();
|
|
||||||
typingIndicator(false);
|
|
||||||
if (d.answer) {
|
|
||||||
addBubble('assistant', d.answer, d.citations);
|
|
||||||
history[active].push({role: 'assistant', content: d.answer, citations: d.citations});
|
|
||||||
} else {
|
|
||||||
addBubble('assistant', d.error || 'Something went wrong. Please try again.', null);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
typingIndicator(false);
|
|
||||||
addBubble('assistant', 'Could not reach the legal engine. Please try again.', null);
|
|
||||||
} finally {
|
|
||||||
sendBtn.disabled = false; input.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendBtn.onclick = send;
|
|
||||||
input.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
|
||||||
});
|
|
||||||
input.addEventListener('input', () => {
|
|
||||||
input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 140) + 'px';
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Boot ----
|
|
||||||
renderGrid(); renderChips(); selectAgent(active, false);
|
|
||||||
fetch('/api/health').then(r => r.json()).then(d => {
|
|
||||||
if (d.ok) {
|
|
||||||
statusText.textContent = d.index.ready
|
|
||||||
? `${d.agent_count} agents · ${d.index.chunks} law passages indexed`
|
|
||||||
: 'Indexing law library…';
|
|
||||||
}
|
|
||||||
}).catch(() => { statusText.textContent = 'Engine warming up'; });
|
|
||||||
</script>
|
</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
124
templates/landing.html
Normal 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
33
tts.py
Normal 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()]
|
||||||
Reference in New Issue
Block a user