b733e551a0
Add scripts/normalize_ambiguous_unicode.py; fix corrupted patch_instance_theme_templates.py. Preserves curly quotes in string literals; removes Git homoglyph warnings on .env.example. Co-authored-by: Cursor <cursoragent@cursor.com>
303 lines
9.2 KiB
Python
303 lines
9.2 KiB
Python
"""中控 AI:JSON 持久化(与 hub_settings.json 同目录)."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from hub_ai.config import CHAT_SESSION_RETENTION_DAYS, SUMMARY_RETENTION_DAYS
|
|
|
|
HUB_DIR = Path(__file__).resolve().parent.parent
|
|
SUMMARIES_PATH = HUB_DIR / "hub_ai_summaries.json"
|
|
CHAT_PATH = HUB_DIR / "hub_ai_chat.json"
|
|
|
|
|
|
def _now_str() -> str:
|
|
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
|
def _atomic_write(path: Path, data: dict) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def _load_json(path: Path, default: dict) -> dict:
|
|
if not path.is_file():
|
|
return dict(default)
|
|
try:
|
|
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
if isinstance(loaded, dict):
|
|
return loaded
|
|
except Exception:
|
|
pass
|
|
return dict(default)
|
|
|
|
|
|
def _prune_summaries(items: list, *, keep_days: int) -> list:
|
|
cutoff = (datetime.now() - timedelta(days=max(1, keep_days))).strftime("%Y-%m-%d")
|
|
out = [x for x in items if str(x.get("trading_day") or "") >= cutoff]
|
|
return out[-500:]
|
|
|
|
|
|
def _prune_chat_sessions(sessions: list, *, keep_days: int) -> list:
|
|
cutoff_dt = datetime.now() - timedelta(days=max(1, keep_days))
|
|
out = []
|
|
for s in sessions:
|
|
ts = str(s.get("updated_at") or s.get("created_at") or "")
|
|
try:
|
|
dt = datetime.strptime(ts[:19], "%Y-%m-%d %H:%M:%S")
|
|
except ValueError:
|
|
out.append(s)
|
|
continue
|
|
if dt >= cutoff_dt:
|
|
out.append(s)
|
|
return out[-50:]
|
|
|
|
|
|
def load_summaries_store() -> dict:
|
|
return _load_json(SUMMARIES_PATH, {"version": 1, "summaries": []})
|
|
|
|
|
|
def save_summaries_store(data: dict) -> None:
|
|
summaries = _prune_summaries(
|
|
list(data.get("summaries") or []),
|
|
keep_days=SUMMARY_RETENTION_DAYS,
|
|
)
|
|
_atomic_write(SUMMARIES_PATH, {"version": 1, "summaries": summaries})
|
|
|
|
|
|
def append_summary(
|
|
*,
|
|
trading_day: str,
|
|
content_md: str,
|
|
model: str,
|
|
context_hash: str,
|
|
stats_snapshot: dict,
|
|
) -> dict:
|
|
store = load_summaries_store()
|
|
row = {
|
|
"id": uuid.uuid4().hex,
|
|
"trading_day": trading_day,
|
|
"generated_at": _now_str(),
|
|
"model": model,
|
|
"context_hash": context_hash,
|
|
"content_md": content_md,
|
|
"stats_snapshot": stats_snapshot,
|
|
}
|
|
store.setdefault("summaries", []).append(row)
|
|
save_summaries_store(store)
|
|
return row
|
|
|
|
|
|
def list_summaries(*, trading_day: Optional[str] = None, limit: int = 30) -> list[dict]:
|
|
store = load_summaries_store()
|
|
items = list(store.get("summaries") or [])
|
|
if trading_day:
|
|
items = [x for x in items if str(x.get("trading_day")) == trading_day]
|
|
items.sort(key=lambda x: str(x.get("generated_at") or ""), reverse=True)
|
|
return items[: max(1, min(limit, 100))]
|
|
|
|
|
|
def get_latest_summary(trading_day: str) -> Optional[dict]:
|
|
rows = list_summaries(trading_day=trading_day, limit=1)
|
|
return rows[0] if rows else None
|
|
|
|
|
|
def load_chat_store() -> dict:
|
|
default = {"version": 1, "sessions": [], "active_session_id": None}
|
|
data = _load_json(CHAT_PATH, default)
|
|
data.setdefault("version", 1)
|
|
data.setdefault("sessions", [])
|
|
return data
|
|
|
|
|
|
def save_chat_store(data: dict) -> None:
|
|
sessions = _prune_chat_sessions(
|
|
list(data.get("sessions") or []),
|
|
keep_days=CHAT_SESSION_RETENTION_DAYS,
|
|
)
|
|
active = data.get("active_session_id")
|
|
ids = {str(s.get("id")) for s in sessions}
|
|
if active and str(active) not in ids:
|
|
active = sessions[-1]["id"] if sessions else None
|
|
_atomic_write(
|
|
CHAT_PATH,
|
|
{"version": 1, "sessions": sessions, "active_session_id": active},
|
|
)
|
|
|
|
|
|
def get_active_session() -> Optional[dict]:
|
|
store = load_chat_store()
|
|
sid = store.get("active_session_id")
|
|
for s in store.get("sessions") or []:
|
|
if str(s.get("id")) == str(sid):
|
|
return s
|
|
return None
|
|
|
|
|
|
CHAT_BOT_TRADING = "trading"
|
|
CHAT_BOT_GENERAL = "general"
|
|
CHAT_BOT_SUPERVISOR = "supervisor"
|
|
CHAT_BOT_MODES = frozenset({CHAT_BOT_TRADING, CHAT_BOT_GENERAL, CHAT_BOT_SUPERVISOR})
|
|
|
|
|
|
def _normalize_bot_mode(raw: Any) -> str:
|
|
mode = (raw or CHAT_BOT_TRADING).strip().lower()
|
|
return mode if mode in CHAT_BOT_MODES else CHAT_BOT_TRADING
|
|
|
|
|
|
def create_new_session(
|
|
*,
|
|
trading_day: str,
|
|
title: str = "新对话",
|
|
bot_mode: str = CHAT_BOT_TRADING,
|
|
) -> dict:
|
|
store = load_chat_store()
|
|
session = {
|
|
"id": uuid.uuid4().hex,
|
|
"trading_day": trading_day,
|
|
"title": title,
|
|
"bot_mode": _normalize_bot_mode(bot_mode),
|
|
"created_at": _now_str(),
|
|
"updated_at": _now_str(),
|
|
"messages": [],
|
|
"rolling_summary": "",
|
|
}
|
|
store.setdefault("sessions", []).append(session)
|
|
store["active_session_id"] = session["id"]
|
|
save_chat_store(store)
|
|
return session
|
|
|
|
|
|
def ensure_active_session(*, trading_day: str) -> dict:
|
|
active = get_active_session()
|
|
if active:
|
|
return active
|
|
return create_new_session(trading_day=trading_day)
|
|
|
|
|
|
def update_session_rolling_summary(session_id: str, summary: str) -> dict:
|
|
store = load_chat_store()
|
|
target = None
|
|
for s in store.get("sessions") or []:
|
|
if str(s.get("id")) == str(session_id):
|
|
target = s
|
|
break
|
|
if not target:
|
|
raise KeyError("session_not_found")
|
|
target["rolling_summary"] = str(summary or "").strip()
|
|
target["updated_at"] = _now_str()
|
|
store["active_session_id"] = target["id"]
|
|
save_chat_store(store)
|
|
return target
|
|
|
|
|
|
def append_chat_message(
|
|
session_id: str,
|
|
role: str,
|
|
content: str,
|
|
*,
|
|
attachments: Optional[list] = None,
|
|
) -> dict:
|
|
store = load_chat_store()
|
|
sessions = store.get("sessions") or []
|
|
target = None
|
|
for s in sessions:
|
|
if str(s.get("id")) == str(session_id):
|
|
target = s
|
|
break
|
|
if not target:
|
|
raise KeyError("session_not_found")
|
|
msg = {"role": role, "content": content.strip(), "at": _now_str()}
|
|
if attachments:
|
|
msg["attachments"] = list(attachments)
|
|
target.setdefault("messages", []).append(msg)
|
|
target["updated_at"] = _now_str()
|
|
if role == "user" and (target.get("title") in (None, "", "新对话")):
|
|
title = content.strip().replace("\n", " ")[:24]
|
|
if title:
|
|
target["title"] = title
|
|
store["active_session_id"] = target["id"]
|
|
save_chat_store(store)
|
|
return target
|
|
|
|
|
|
def _session_list_item(s: dict, *, active_id: Optional[str]) -> dict:
|
|
msgs = s.get("messages") or []
|
|
preview = ""
|
|
for m in reversed(msgs):
|
|
if m.get("role") == "user":
|
|
preview = str(m.get("content") or "").replace("\n", " ")[:48]
|
|
break
|
|
if not preview and msgs:
|
|
last = msgs[-1]
|
|
preview = str(last.get("content") or "").replace("\n", " ")[:48]
|
|
sid = str(s.get("id") or "")
|
|
return {
|
|
"id": sid,
|
|
"title": s.get("title") or "新对话",
|
|
"bot_mode": _normalize_bot_mode(s.get("bot_mode")),
|
|
"trading_day": s.get("trading_day"),
|
|
"created_at": s.get("created_at"),
|
|
"updated_at": s.get("updated_at"),
|
|
"message_count": len(msgs),
|
|
"preview": preview,
|
|
"is_active": sid and sid == str(active_id or ""),
|
|
}
|
|
|
|
|
|
def list_chat_sessions(*, limit: int = 50) -> list[dict]:
|
|
store = load_chat_store()
|
|
active_id = store.get("active_session_id")
|
|
sessions = list(store.get("sessions") or [])
|
|
for s in sessions:
|
|
s.setdefault("bot_mode", CHAT_BOT_TRADING)
|
|
sessions.sort(key=lambda x: str(x.get("updated_at") or ""), reverse=True)
|
|
return [_session_list_item(s, active_id=active_id) for s in sessions[: max(1, min(limit, 100))]]
|
|
|
|
|
|
def set_active_session(session_id: str) -> dict:
|
|
store = load_chat_store()
|
|
target = None
|
|
for s in store.get("sessions") or []:
|
|
if str(s.get("id")) == str(session_id):
|
|
target = s
|
|
break
|
|
if not target:
|
|
raise KeyError("session_not_found")
|
|
target.setdefault("bot_mode", CHAT_BOT_TRADING)
|
|
store["active_session_id"] = target["id"]
|
|
save_chat_store(store)
|
|
return target
|
|
|
|
|
|
def delete_chat_session(session_id: str) -> tuple[bool, Optional[str]]:
|
|
store = load_chat_store()
|
|
sessions = list(store.get("sessions") or [])
|
|
new_sessions = [s for s in sessions if str(s.get("id")) != str(session_id)]
|
|
if len(new_sessions) == len(sessions):
|
|
return False, None
|
|
active = store.get("active_session_id")
|
|
new_active = active
|
|
if str(active) == str(session_id):
|
|
new_active = new_sessions[0]["id"] if new_sessions else None
|
|
store["sessions"] = new_sessions
|
|
store["active_session_id"] = new_active
|
|
save_chat_store(store)
|
|
return True, new_active
|
|
|
|
|
|
def summary_excerpt_for_chat(trading_day: str, max_chars: int = 600) -> str:
|
|
latest = get_latest_summary(trading_day)
|
|
if not latest:
|
|
return ""
|
|
text = str(latest.get("content_md") or "").strip()
|
|
if len(text) <= max_chars:
|
|
return text
|
|
return text[: max_chars - 3].rstrip() + "..."
|