feat: chat + optmz control panel
This commit is contained in:
@@ -284,17 +284,17 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
|
||||
return deterministic, {"mode": "deterministic", "reason": "provider_unresolved", "provider": provider_id}
|
||||
|
||||
pclass = provider.get("class", "local")
|
||||
if pclass == "cloud" and provider.get("requires_key"):
|
||||
if provider.get("requires_key"):
|
||||
key_env = provider.get("key_env", "")
|
||||
if key_env and not os.environ.get(key_env):
|
||||
# Honest: do not silently downgrade a cloud request to a fake answer.
|
||||
return deterministic, {"mode": "deterministic", "reason": "cloud_key_unset", "provider": provider_id}
|
||||
return deterministic, {"mode": "deterministic", "reason": "provider_key_unset", "provider": provider_id}
|
||||
|
||||
router = os.environ.get("CASAN_CHAT_MODEL_ROUTER") or MODEL_ROUTER
|
||||
env = os.environ.copy()
|
||||
if pclass == "cloud":
|
||||
# Data policy 18.M.2: PII/secret must not reach a cloud model without the
|
||||
# C3 guard. Force the model-router preflight for any cloud-class provider.
|
||||
if pclass == "cloud" or provider.get("requires_preflight"):
|
||||
# Data policy 18.M.2: PII/secret must not reach a cloud or gateway model
|
||||
# without the C3 guard. Force the model-router preflight for either route.
|
||||
env["CASAN_PREFLIGHT"] = "1"
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
@@ -306,6 +306,22 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
|
||||
["bash", router, pf, oj, "--role", "generate", "--model", model_spec],
|
||||
cwd=ROOT, capture_output=True, text=True, env=env,
|
||||
)
|
||||
# A locally available model is the safe operational fallback when a
|
||||
# configured cloud/gateway route is unavailable. The input is already
|
||||
# H4-scanned and the output still passes H4 below; we never fall back to
|
||||
# another network provider or bypass the router.
|
||||
fallback_from = ""
|
||||
if (r.returncode != 0 or not os.path.isfile(oj)) and provider_id != "local":
|
||||
fallback = providers.get(os.environ.get("CASAN_CHAT_LOCAL_FALLBACK_PROVIDER", "local"), {})
|
||||
fallback_model = fallback.get("model")
|
||||
if fallback_model and fallback.get("class", "local") == "local":
|
||||
fallback_from = provider_id
|
||||
r = subprocess.run(
|
||||
["bash", router, pf, oj, "--role", "generate", "--model", fallback_model],
|
||||
cwd=ROOT, capture_output=True, text=True, env=os.environ.copy(),
|
||||
)
|
||||
if r.returncode == 0 and os.path.isfile(oj):
|
||||
provider_id, provider, model_spec, pclass = "local", fallback, fallback_model, "local"
|
||||
if r.returncode != 0 or not os.path.isfile(oj):
|
||||
return deterministic, {
|
||||
"mode": "deterministic",
|
||||
@@ -328,6 +344,7 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
|
||||
"ollama": "ollama_local_real_tokens",
|
||||
"openai": "openai_api_real_tokens",
|
||||
"anthropic": "anthropic_api_real_tokens",
|
||||
"openai-compatible": "openai_compatible_api_real_tokens",
|
||||
}.get(_backend_of(model_spec), "model_real_tokens")
|
||||
return answer, {
|
||||
"mode": "model",
|
||||
@@ -338,6 +355,7 @@ def synthesize_answer(message: str, sources, role: str = "read_only", history: s
|
||||
"input_tokens": int(out.get("input_tokens") or 0),
|
||||
"output_tokens": int(out.get("output_tokens") or 0),
|
||||
"cost_source": cost_source,
|
||||
"fallback_from": fallback_from or None,
|
||||
}
|
||||
|
||||
|
||||
@@ -585,6 +603,76 @@ def verify_audit() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def history(args) -> int:
|
||||
"""Return a privacy-minimised, integrity-checked view of one actor's chats.
|
||||
|
||||
The Control Panel never reads the audit file itself. This harness command
|
||||
keeps tenant-path resolution, chain verification and field minimisation in
|
||||
the same trust boundary as chat writes. It exposes H4-scanned prompt
|
||||
previews and bounded governed-output previews only, never a raw user
|
||||
message or full audit record.
|
||||
"""
|
||||
if args.tenant and args.tenant != "default":
|
||||
os.environ["CASAN_TENANT_ID"] = args.tenant
|
||||
tenant_id = args.tenant or "default"
|
||||
records = []
|
||||
prev = GENESIS_HASH
|
||||
try:
|
||||
with open(audit_path(), encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
if not line.strip():
|
||||
continue
|
||||
rec = json.loads(line)
|
||||
rest = {k: v for k, v in rec.items() if k != "record_hash"}
|
||||
if rest.get("prev_hash") != prev or sha(json.dumps(rest, sort_keys=True, ensure_ascii=False)) != rec.get("record_hash"):
|
||||
print(json.dumps({"ok": False, "reason": "chat_chain_broken"}, ensure_ascii=False))
|
||||
return 3
|
||||
prev = rec["record_hash"]
|
||||
if rec.get("tenant_id", "default") == tenant_id and rec.get("actor") == args.actor:
|
||||
records.append(rec)
|
||||
except OSError:
|
||||
records = []
|
||||
|
||||
conversations = {}
|
||||
for rec in records:
|
||||
chat_id = rec.get("chat_id") or "chat-default"
|
||||
current = conversations.get(chat_id)
|
||||
item = {
|
||||
"chat_id": chat_id,
|
||||
"title": (rec.get("safe_preview") or rec.get("answer_preview") or "Governed chat")[:80],
|
||||
"updated_at": rec.get("timestamp") or "",
|
||||
"turns": 1,
|
||||
"last_decision": rec.get("decision") or "UNKNOWN",
|
||||
"last_mode": rec.get("mode") or "READ_ONLY",
|
||||
}
|
||||
if current:
|
||||
item["turns"] = current["turns"] + 1
|
||||
if current.get("updated_at", "") > item["updated_at"]:
|
||||
item = current
|
||||
conversations[chat_id] = item
|
||||
|
||||
selected = records if not args.chat_id else [r for r in records if r.get("chat_id") == args.chat_id]
|
||||
selected = selected[-max(1, min(args.limit, 100)):]
|
||||
turns = [{
|
||||
"chat_id": rec.get("chat_id") or "chat-default",
|
||||
"turn_id": rec.get("turn_id") or "",
|
||||
"timestamp": rec.get("timestamp") or "",
|
||||
"mode": rec.get("mode") or "READ_ONLY",
|
||||
"risk": rec.get("risk") or "low",
|
||||
"decision": rec.get("decision") or "UNKNOWN",
|
||||
"prompt_preview": rec.get("safe_preview") or "",
|
||||
"answer_preview": (rec.get("answer_preview") or rec.get("answer") or "")[:180],
|
||||
"certified": rec.get("decision") == "ANSWERED",
|
||||
"audit_hash": rec.get("record_hash") or "",
|
||||
} for rec in selected]
|
||||
print(json.dumps({
|
||||
"ok": True,
|
||||
"conversations": sorted(conversations.values(), key=lambda item: item.get("updated_at", ""), reverse=True),
|
||||
"turns": turns,
|
||||
}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
@@ -596,11 +684,18 @@ def main() -> int:
|
||||
askp.add_argument("--tenant", default="default")
|
||||
askp.add_argument("--stream", action="store_true")
|
||||
sub.add_parser("verify-audit")
|
||||
hp = sub.add_parser("history")
|
||||
hp.add_argument("--actor", required=True)
|
||||
hp.add_argument("--chat-id", default="")
|
||||
hp.add_argument("--tenant", default="default")
|
||||
hp.add_argument("--limit", type=int, default=50)
|
||||
args = ap.parse_args()
|
||||
if args.cmd == "ask":
|
||||
return ask(args)
|
||||
if args.cmd == "verify-audit":
|
||||
return verify_audit()
|
||||
if args.cmd == "history":
|
||||
return history(args)
|
||||
return 2
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user