initial: blog-app snapshot

This commit is contained in:
omo
2026-08-17 17:11:27 +08:00
commit 65cb9d5ead
125 changed files with 14720 additions and 0 deletions

346
services/analysis.py Normal file
View File

@@ -0,0 +1,346 @@
"""Analysis service: context building, LLM prompt assembly, SSE event framing.
Pure functions — no Flask imports. All DB access goes through an explicit
sqlite3 connection argument (row_factory=sqlite3.Row expected), so this
module can also be imported by scripts/daily_watchdog.py without an app
context. Error values are plain `(dict, status)` tuples; routes jsonify them.
"""
import json
import logging
import time
import requests as http_requests
import config
from repositories import issue_repo, project_repo, release_repo
logger = logging.getLogger(__name__)
DELIM_REASONING = "===REASONING==="
DELIM_CONCLUSION = "===CONCLUSION==="
PRESET_MAP = {
"activity": "请基于最近 release + issue 评估项目活跃度 (1-10 分) 并给出证据",
"evolution": "请按时间列 5 个关键 release 的变更重点, 给出演进方向判断",
"issues_hot": "请把 issue 按主题聚类, 列 top 3 热点, 每条给 1 句描述",
}
PRESET_LIST = [
{"id": "activity", "label": "项目活跃度评估", "icon": "📊", "description": "基于最近 release + issue 评估项目活跃度 (1-10) + 证据"},
{"id": "evolution", "label": "版本演进分析", "icon": "🚀", "description": "按时间列 5 个关键 release 的变更重点, 给演进方向判断"},
{"id": "issues_hot", "label": "Issue 热点聚类", "icon": "🔥", "description": "把 issue 按主题聚类, 列 top 3 热点 + 每条 1 句描述"},
]
SYSTEM_PROMPT = "你是 AI 项目分析助手, 基于以下数据进行分析。请用中文回答。"
# Prompt trimming budgets live in config (env-overridable, shared with
# scripts/daily_watchdog.py). Re-exported for convenient imports/tests.
LLM_BODY_LIMIT = config.LLM_BODY_LIMIT
LLM_FIELD_LIMIT = config.LLM_FIELD_LIMIT
LLM_MESSAGE_LIMIT = config.LLM_MESSAGE_LIMIT
LLM_TOTAL_LIMIT = config.LLM_TOTAL_LIMIT
FORMAT_REQ = (
"要求: 严格按以下格式输出, 不要输出其他任何标记:\n"
"===REASONING===\n"
"(这里写你的分析推理过程: 数据观察、初步判断、深入推理)\n"
"===CONCLUSION===\n"
"(这里写最终结论: 先给一句总结论, 再用 2-3 句概括关键依据, 不少于 50 字)"
)
def get_presets():
return {"presets": PRESET_LIST}
def resolve_question(question: str, preset: str) -> str:
"""If only a preset id is given, map it to the canonical question text."""
if not question and preset:
return PRESET_MAP.get(preset, preset)
return question
def build_context(scope: str, db_conn):
"""Build the context dict for a scope. Single source of truth used by
/api/context/<scope> and /api/analyze/stream.
Returns (result_dict_or_None, error_or_None). The error is a plain
(payload_dict, status_code) tuple — the route jsonifies it.
"""
result = {
"scope_type": scope,
"project_id": None,
"data_summary": "",
"releases": [],
"issues": [],
"stats": {},
}
if scope == "home":
stats = project_repo.get_stats(db_conn)
result["stats"] = stats
result["data_summary"] = (
f"{stats['total_projects']} 个项目, {stats['total_releases']} 个 release, "
f"{stats['total_issues']} 个 issue, {stats['total_stars']} stars"
)
result["releases"] = release_repo.list_recent_releases_slim(db_conn, limit=5)
result["issues"] = issue_repo.list_recent_issues(db_conn, limit=10)
elif scope.startswith("project/"):
try:
pid = int(scope.split("/")[1])
except (IndexError, ValueError):
return None, ({"error": "invalid project scope"}, 400)
result["project_id"] = pid
proj = project_repo.get_project(db_conn, pid)
if proj is None:
return None, ({"error": "project not found"}, 404)
result["data_summary"] = (
f"项目: {proj['name']} ({proj.get('full_name', '')}), {proj.get('stars', 0)} stars"
)
result["releases"] = release_repo.list_releases_for_project_slim(db_conn, pid, limit=5)
result["issues"] = issue_repo.list_issues_for_project(db_conn, pid, limit=10)
elif scope == "projects":
projects = project_repo.list_project_summaries(db_conn)
result["data_summary"] = f"{len(projects)} 个项目"
result["releases"] = release_repo.list_recent_releases_slim(db_conn, limit=5)
result["issues"] = issue_repo.list_recent_issues(db_conn, limit=10)
elif scope.startswith("search/"):
query = scope[7:]
result["data_summary"] = f"搜索: {query}"
result["releases"] = release_repo.search_releases_slim(db_conn, query, limit=5)
result["issues"] = issue_repo.search_issues(db_conn, query, limit=10)
else:
return None, ({"error": "unknown scope"}, 400)
return result, None
def _truncate(text, limit):
"""Truncate a string to limit chars, appending '...' when cut."""
if not isinstance(text, str):
return text
if len(text) <= limit:
return text
return text[:limit] + "..."
def _slim_for_llm(data):
"""Return a trimmed copy of the context dict for the LLM prompt.
Keeps only the fields the model needs and truncates long bodies so the
serialized JSON stays small enough for fast prefill.
"""
slim = dict(data)
if isinstance(data.get("releases"), list):
slim_releases = []
for rel in data["releases"]:
if not isinstance(rel, dict):
slim_releases.append(rel)
continue
slim_releases.append({
"tag_name": rel.get("tag_name"),
"name": rel.get("name"),
"published_at": rel.get("published_at"),
"body": _truncate(rel.get("body", ""), LLM_BODY_LIMIT),
})
slim["releases"] = slim_releases
if isinstance(data.get("issues"), list):
slim_issues = []
for issue in data["issues"]:
if not isinstance(issue, dict):
slim_issues.append(issue)
continue
item = {
"title": issue.get("title"),
"state": issue.get("state"),
"created_at": issue.get("created_at"),
}
if "body" in issue:
item["body"] = _truncate(issue.get("body") or "", LLM_FIELD_LIMIT)
slim_issues.append(item)
slim["issues"] = slim_issues
if isinstance(data.get("messages"), list):
slim["messages"] = [
_truncate(m, LLM_MESSAGE_LIMIT) if isinstance(m, str)
else {**m, "content": _truncate(m.get("content", ""), LLM_MESSAGE_LIMIT)} if isinstance(m, dict)
else m
for m in data["messages"]
]
serialized = json.dumps(slim, ensure_ascii=False, default=str)
if len(serialized) > LLM_TOTAL_LIMIT:
if isinstance(slim.get("releases"), list):
slim["releases"] = slim["releases"][:3]
if isinstance(slim.get("issues"), list):
slim["issues"] = slim["issues"][:5]
logger.info(
"_slim_for_llm: payload still >%d chars, cut to %d releases / %d issues",
LLM_TOTAL_LIMIT,
len(slim.get("releases", [])),
len(slim.get("issues", [])),
)
return slim
def build_prompts(data, question):
"""Assemble system/user messages + the human-readable prompt display."""
slim_data = _slim_for_llm(data)
data_json = json.dumps(slim_data, ensure_ascii=False, default=str)
user_prompt = f"分析数据: {data_json}\n\n用户问题: {question}\n\n{FORMAT_REQ}"
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
]
full_prompt_display = f"[SYSTEM]\n{SYSTEM_PROMPT}\n\n[USER]\n{user_prompt}"
return messages, full_prompt_display
def _sse(event, payload):
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
def stream_analysis_events(data, question):
"""Generator yielding SSE frames: step/token/section_start/error/complete.
Pure of Flask: safe to consume after the request context has closed,
as long as `data` and `question` were prepared inside the view.
"""
start_time = time.time()
data_summary = data["data_summary"]
messages, full_prompt_display = build_prompts(data, question)
yield _sse("step", {"step": "context", "content": f"已收集上下文: {data_summary}"})
yield _sse("step", {"step": "prompt", "content": full_prompt_display})
def emit_token(t):
return _sse("token", {"text": t})
try:
resp = http_requests.post(
f"{config.LLM_URL}/chat/completions",
json={"model": config.LLM_MODEL, "messages": messages, "stream": True, "max_tokens": 1024},
stream=True,
timeout=config.LLM_TIMEOUT,
)
resp.raise_for_status()
last_keepalive = time.time()
holdback = max(len(DELIM_REASONING), len(DELIM_CONCLUSION)) - 1
pending = ""
reasoning_started = False
conclusion_started = False
for line in resp.iter_lines():
if not line:
continue
line = line.decode("utf-8") if isinstance(line, bytes) else line
now = time.time()
if now - last_keepalive > 15:
yield ": keepalive\n\n"
last_keepalive = now
if line.startswith("data: "):
chunk_data = line[6:]
if chunk_data.strip() == "[DONE]":
break
try:
chunk = json.loads(chunk_data)
delta = chunk.get("choices", [{}])[0].get("delta", {})
token_text = delta.get("content", "")
except json.JSONDecodeError:
continue
if not token_text:
continue
pending += token_text
# 滚动 buffer: 分隔符可能跨 chunk, 保留尾部前缀再下发
while pending:
idx_r = pending.find(DELIM_REASONING)
idx_c = pending.find(DELIM_CONCLUSION)
idx = -1
delim = None
if idx_r != -1 and (idx_c == -1 or idx_r < idx_c):
idx, delim = idx_r, DELIM_REASONING
elif idx_c != -1:
idx, delim = idx_c, DELIM_CONCLUSION
if idx != -1:
if idx > 0:
if not reasoning_started:
reasoning_started = True
yield "event: reasoning_start\ndata: {}\n\n"
yield emit_token(pending[:idx])
pending = pending[idx + len(delim):]
if delim == DELIM_REASONING:
if not reasoning_started:
reasoning_started = True
yield "event: reasoning_start\ndata: {}\n\n"
else:
if not reasoning_started:
reasoning_started = True
yield "event: reasoning_start\ndata: {}\n\n"
if not conclusion_started:
conclusion_started = True
yield "event: conclusion_start\ndata: {}\n\n"
continue
keep = 0
tail = pending[-holdback:] if len(pending) > holdback else pending
for d in (DELIM_REASONING, DELIM_CONCLUSION):
for k in range(min(len(tail), len(d) - 1), 0, -1):
if tail.endswith(d[:k]):
keep = max(keep, k)
break
safe = pending[:len(pending) - keep] if keep else pending
pending = pending[len(safe):]
if not safe:
break
if not reasoning_started:
reasoning_started = True
yield "event: reasoning_start\ndata: {}\n\n"
yield emit_token(safe)
if pending:
if not reasoning_started:
reasoning_started = True
yield "event: reasoning_start\ndata: {}\n\n"
yield emit_token(pending)
except Exception as e:
yield _sse("error", {"error": f"LLM call failed: {str(e)}"})
return
# 防御: 模型没给分隔符时补发 section_start, 保证前端 tab 一定有入口
if not reasoning_started:
yield "event: reasoning_start\ndata: {}\n\n"
if not conclusion_started:
yield "event: conclusion_start\ndata: {}\n\n"
elapsed = round(time.time() - start_time, 1)
yield _sse("step", {"step": "complete", "content": f"分析完成, 总耗时 {elapsed}"})
def summarize_with_llm(messages, model=None, timeout=None):
"""Non-streaming LLM call returning the assistant content string.
Kept Flask-free so scripts/daily_watchdog.py (and future cron jobs) can
reuse the same endpoint configuration.
"""
resp = http_requests.post(
f"{config.LLM_URL}/chat/completions",
json={
"model": model or config.LLM_MODEL,
"messages": messages,
"stream": False,
"max_tokens": 1024,
},
timeout=timeout or config.LLM_TIMEOUT,
)
resp.raise_for_status()
return resp.json().get("choices", [{}])[0].get("message", {}).get("content", "")