Files
CateringInformationBank/app.py
T
2026-07-19 18:11:08 +08:00

221 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Local Hongcan article library and Qwen-assisted editorial analysis."""
from __future__ import annotations
import json
import os
import sqlite3
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
from flask import Flask, abort, jsonify, render_template, request
BASE_DIR = Path(__file__).resolve().parent
DB_PATH = Path(os.getenv("HONGCAN_DB", BASE_DIR / "data" / "hongcan.db"))
load_dotenv(BASE_DIR / ".env")
app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY", "hongcan-local-only")
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def ensure_schema() -> None:
with db() as conn:
conn.executescript((BASE_DIR / "schema.sql").read_text(encoding="utf-8"))
def clamp_int(value: str | None, default: int, low: int, high: int) -> int:
try:
return max(low, min(high, int(value or default)))
except ValueError:
return default
TREND_CN = {"emerging": "新兴", "accelerating": "加速", "stable": "稳定", "declining": "下行"}
@app.template_filter("date_cn")
def date_cn(value: str | None) -> str:
if not value:
return "时间未知"
try:
return datetime.fromisoformat(value).strftime("%Y.%m.%d %H:%M")
except ValueError:
return value
@app.get("/")
def index():
query = (request.args.get("q") or "").strip()
page = clamp_int(request.args.get("page"), 1, 1, 10_000)
per_page = 18
where = "WHERE crawl_status='success'"
params: list[object] = []
if query:
where += " AND (title LIKE ? OR author LIKE ? OR content LIKE ?)"
needle = f"%{query}%"
params.extend([needle, needle, needle])
with db() as conn:
total = conn.execute(f"SELECT COUNT(*) FROM articles {where}", params).fetchone()[0]
rows = conn.execute(
f"""
SELECT a.id,a.title,a.author,a.published_at,a.summary,a.canonical_url,
substr(a.content,1,180) AS excerpt,
json_extract(x.result_json,'$.summary') AS ai_summary,
CASE WHEN x.id IS NULL THEN 0 ELSE 1 END AS analyzed
FROM articles a
LEFT JOIN ai_analyses x ON x.article_id=a.id AND x.analysis_type='editorial'
{where.replace('crawl_status', 'a.crawl_status')}
ORDER BY a.published_at DESC,a.id DESC LIMIT ? OFFSET ?
""",
[*params, per_page, (page - 1) * per_page],
).fetchall()
stats = conn.execute(
"""
SELECT COUNT(*) total,
SUM(crawl_status='success') success,
MAX(published_at) latest,
(SELECT COUNT(DISTINCT article_id) FROM ai_analyses) analyzed
FROM articles
"""
).fetchone()
signal_rows = conn.execute(
"SELECT * FROM industry_signals ORDER BY signal_date DESC,confidence DESC LIMIT 8"
).fetchall()
signals = []
for signal in signal_rows:
item = dict(signal)
item["tags"] = json.loads(item["tags_json"])
item["implications"] = json.loads(item["implications_json"])
item["article_ids"] = json.loads(item["article_ids_json"])
item["trend"] = TREND_CN.get(item["trend"], item["trend"])
signals.append(item)
return render_template(
"index.html", articles=rows, query=query, page=page, total=total,
pages=max(1, (total + per_page - 1) // per_page), stats=stats, signals=signals,
)
@app.get("/signals/<int:signal_id>")
def signal_detail(signal_id: int):
with db() as conn:
signal = conn.execute("SELECT * FROM industry_signals WHERE id=?", (signal_id,)).fetchone()
if not signal:
abort(404)
signal = dict(signal)
signal["trend"] = TREND_CN.get(signal["trend"], signal["trend"])
article_ids = json.loads(signal["article_ids_json"])
articles = []
if article_ids:
marks = ",".join("?" for _ in article_ids)
articles = conn.execute(
f"SELECT id,title,published_at FROM articles WHERE id IN ({marks}) ORDER BY published_at DESC",
article_ids,
).fetchall()
return render_template(
"signal.html", signal=signal, articles=articles,
implications=json.loads(signal["implications_json"]), tags=json.loads(signal["tags_json"]),
)
@app.get("/article/<int:article_id>")
def article(article_id: int):
with db() as conn:
row = conn.execute("SELECT * FROM articles WHERE id=?", (article_id,)).fetchone()
if not row:
abort(404)
analysis = conn.execute(
"SELECT * FROM ai_analyses WHERE article_id=? AND analysis_type='editorial' ORDER BY updated_at DESC LIMIT 1",
(article_id,),
).fetchone()
parsed = json.loads(analysis["result_json"]) if analysis else None
return render_template("article.html", article=row, analysis=parsed, analysis_row=analysis)
def qwen_analyze(title: str, content: str) -> dict:
api_key = os.getenv("DASHSCOPE_API_KEY", "").strip()
if not api_key:
raise RuntimeError("尚未配置 DASHSCOPE_API_KEY,请在 .env 中设置")
model = os.getenv("QWEN_MODEL", "qwen-plus")
prompt = f"""你是一名资深餐饮行业研究编辑。分析下面这篇文章,只返回合法 JSON,不要 Markdown 代码块。
JSON 字段必须为:summary120字内摘要)、key_points3-6条字符串)、industry_signal(行业信号)、brands(品牌数组)、numbers(关键数据数组)、content_angles3条可延展选题)、risk_notes(事实或表述风险数组)、tags5-10个标签)、sentimentpositive/neutral/negative)。
文章标题:{title}
正文:{content[:24000]}"""
payload = json.dumps({
"model": model,
"messages": [
{"role": "system", "content": "你输出严谨、简洁、可供编辑部直接使用的结构化行业分析。"},
{"role": "user", "content": prompt},
],
"temperature": 0.25,
"response_format": {"type": "json_object"},
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
data=payload,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=90) as response:
body = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"千问接口返回 {exc.code}{detail}") from exc
text = body["choices"][0]["message"]["content"].strip()
if text.startswith("```"):
text = text.strip("`").removeprefix("json").strip()
return json.loads(text)
@app.post("/api/articles/<int:article_id>/analyze")
def analyze(article_id: int):
force = bool((request.get_json(silent=True) or {}).get("force"))
model = os.getenv("QWEN_MODEL", "qwen-plus")
with db() as conn:
row = conn.execute("SELECT * FROM articles WHERE id=?", (article_id,)).fetchone()
if not row:
return jsonify({"ok": False, "error": "文章不存在"}), 404
cached = conn.execute(
"SELECT * FROM ai_analyses WHERE article_id=? AND model=? AND analysis_type='editorial'",
(article_id, model),
).fetchone()
if cached and cached["content_hash"] == row["content_hash"] and not force:
return jsonify({"ok": True, "cached": True, "analysis": json.loads(cached["result_json"])})
try:
result = qwen_analyze(row["title"], row["content"])
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 502
timestamp = now_iso()
conn.execute(
"""
INSERT INTO ai_analyses(article_id,model,analysis_type,result_json,content_hash,created_at,updated_at)
VALUES (?,?, 'editorial', ?,?,?,?)
ON CONFLICT(article_id,model,analysis_type) DO UPDATE SET
result_json=excluded.result_json,content_hash=excluded.content_hash,updated_at=excluded.updated_at
""",
(article_id, model, json.dumps(result, ensure_ascii=False), row["content_hash"], timestamp, timestamp),
)
conn.commit()
return jsonify({"ok": True, "cached": False, "analysis": result})
if __name__ == "__main__":
ensure_schema()
app.run(host="127.0.0.1", port=int(os.getenv("PORT", "8766")), debug=False)