808 lines
28 KiB
Python
808 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""餐智库后端 API 服务。
|
|
|
|
提供前端所需的全部接口:
|
|
- GET /api/dashboard 仪表盘数据
|
|
- GET /api/intel 情报流列表(支持筛选)
|
|
- GET /api/intel/<id> 情报详情
|
|
- GET /api/brands 品牌库列表
|
|
- GET /api/brands/<id> 品牌详情
|
|
- GET /api/analysis 赛道分析数据
|
|
- GET /api/reports 报告列表
|
|
- POST /api/qa/ask RAG 问答
|
|
- POST /api/crawl/run 触发采集
|
|
- POST /api/analyze/run 触发结构化分析
|
|
- GET /api/stats 概览统计
|
|
- GET /api/health 健康检查
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from dotenv import load_dotenv
|
|
from flask import Flask, jsonify, request, send_file, abort, Response, stream_with_context
|
|
from flask_cors import CORS
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
load_dotenv(BASE_DIR / ".env")
|
|
|
|
app = Flask(__name__)
|
|
app.secret_key = os.getenv("FLASK_SECRET_KEY", "cibank-local-only")
|
|
CORS(app)
|
|
|
|
DB_PATH = Path(os.getenv("CIBANK_DB", BASE_DIR / "data" / "cibank.db"))
|
|
|
|
|
|
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:
|
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
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
|
|
|
|
|
|
# ---- 健康检查 ----
|
|
|
|
@app.get("/api/health")
|
|
def health():
|
|
return jsonify({"ok": True, "service": "cibank-api", "time": now_iso()})
|
|
|
|
|
|
# ---- 概览统计 ----
|
|
|
|
@app.get("/api/stats")
|
|
def stats():
|
|
with db() as conn:
|
|
total_articles = conn.execute("SELECT COUNT(*) FROM articles WHERE crawl_status='success'").fetchone()[0]
|
|
total_analyzed = conn.execute("SELECT COUNT(DISTINCT article_id) FROM ai_analyses").fetchone()[0]
|
|
total_brands = conn.execute("SELECT COUNT(*) FROM brands").fetchone()[0]
|
|
total_reports = conn.execute("SELECT COUNT(*) FROM reports").fetchone()[0]
|
|
sources_mp = conn.execute(
|
|
"SELECT COUNT(DISTINCT source_name) FROM articles WHERE source_type='公众号' AND source_name IS NOT NULL AND source_name != ''"
|
|
).fetchone()[0]
|
|
sources_video = conn.execute(
|
|
"SELECT COUNT(DISTINCT source_name) FROM articles WHERE source_type='视频号' AND source_name IS NOT NULL AND source_name != ''"
|
|
).fetchone()[0]
|
|
today = datetime.now().astimezone().date().isoformat()
|
|
today_new = conn.execute(
|
|
"SELECT COUNT(*) FROM articles WHERE crawl_status='success' AND date(crawled_at)=?",
|
|
(today,),
|
|
).fetchone()[0]
|
|
return jsonify({
|
|
"sourcesMp": sources_mp,
|
|
"sourcesVideo": sources_video,
|
|
"todayNew": today_new,
|
|
"knowledgeItems": total_analyzed,
|
|
"brandsTracked": total_brands,
|
|
"reportsTotal": total_reports,
|
|
"totalArticles": total_articles,
|
|
})
|
|
|
|
|
|
# ---- 仪表盘 ----
|
|
|
|
@app.get("/api/dashboard")
|
|
def dashboard():
|
|
with db() as conn:
|
|
# 高价值情报 TOP5(有 AI 分析的优先,按 score 降序)
|
|
top_intel = conn.execute(
|
|
"""
|
|
SELECT a.id, a.title, a.source_name, a.source_type, a.published_at, a.category,
|
|
json_extract(x.result_json, '$.summary') AS ai_summary,
|
|
COALESCE(json_extract(x.result_json, '$.type'), '品牌动态') AS type,
|
|
COALESCE(CAST(json_extract(x.result_json, '$.score') AS INTEGER), 0) AS score,
|
|
json_extract(x.result_json, '$.brands') AS entities,
|
|
x.result_json IS NOT NULL AS has_analysis
|
|
FROM articles a
|
|
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
|
|
WHERE a.crawl_status = 'success'
|
|
ORDER BY has_analysis DESC, score DESC, a.published_at DESC
|
|
LIMIT 5
|
|
"""
|
|
).fetchall()
|
|
|
|
# 政策预警
|
|
policy_alerts = conn.execute(
|
|
"""
|
|
SELECT a.id, a.title, a.source_name, a.published_at,
|
|
json_extract(x.result_json, '$.summary') AS ai_summary
|
|
FROM articles a
|
|
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
|
|
WHERE a.crawl_status = 'success'
|
|
AND json_extract(x.result_json, '$.type') = '政策监管'
|
|
ORDER BY a.published_at DESC
|
|
LIMIT 3
|
|
"""
|
|
).fetchall()
|
|
|
|
# 热词统计(从标签中聚合)
|
|
tag_rows = conn.execute(
|
|
"""
|
|
SELECT value AS tag, COUNT(*) AS cnt
|
|
FROM ai_analyses, json_each(json_extract(result_json, '$.tags'))
|
|
GROUP BY value
|
|
ORDER BY cnt DESC
|
|
LIMIT 8
|
|
"""
|
|
).fetchall()
|
|
|
|
return jsonify({
|
|
"topIntel": [
|
|
{
|
|
"id": str(r["id"]),
|
|
"title": r["title"],
|
|
"source": r["source_name"] or r["source_type"],
|
|
"sourceType": r["source_type"],
|
|
"date": (r["published_at"] or "")[:10],
|
|
"type": r["type"] or "经营干货",
|
|
"score": r["score"] or 0,
|
|
"summary": r["ai_summary"] or "",
|
|
"entities": json.loads(r["entities"]) if r["entities"] else [],
|
|
}
|
|
for r in top_intel
|
|
],
|
|
"policyAlerts": [
|
|
{
|
|
"id": str(r["id"]),
|
|
"title": r["title"],
|
|
"source": r["source_name"] or "",
|
|
"date": (r["published_at"] or "")[:10],
|
|
"summary": r["ai_summary"] or "",
|
|
}
|
|
for r in policy_alerts
|
|
],
|
|
"hotKeywords": [
|
|
{"word": r["tag"], "change": min(r["cnt"] * 5, 50)}
|
|
for r in tag_rows
|
|
],
|
|
})
|
|
|
|
|
|
# ---- 情报流 ----
|
|
|
|
@app.get("/api/intel")
|
|
def intel_list():
|
|
type_filter = request.args.get("type", "")
|
|
category = request.args.get("category", "")
|
|
query = (request.args.get("q") or "").strip()
|
|
page = clamp_int(request.args.get("page"), 1, 1, 10_000)
|
|
per_page = clamp_int(request.args.get("per_page"), 20, 1, 100)
|
|
|
|
where = "WHERE a.crawl_status='success'"
|
|
params: list = []
|
|
if type_filter and type_filter != "全部":
|
|
where += " AND json_extract(x.result_json, '$.type') = ?"
|
|
params.append(type_filter)
|
|
if category and category != "全部赛道":
|
|
where += " AND a.category = ?"
|
|
params.append(category)
|
|
if query:
|
|
where += " AND (a.title LIKE ? OR a.summary LIKE ? OR a.content LIKE ?)"
|
|
needle = f"%{query}%"
|
|
params.extend([needle, needle, needle])
|
|
|
|
with db() as conn:
|
|
total = conn.execute(
|
|
f"""
|
|
SELECT COUNT(*) FROM articles a
|
|
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
|
|
{where}
|
|
""",
|
|
params,
|
|
).fetchone()[0]
|
|
|
|
rows = conn.execute(
|
|
f"""
|
|
SELECT a.id, a.title, a.source_name, a.source_type, a.published_at, a.category,
|
|
a.summary, a.canonical_url,
|
|
json_extract(x.result_json, '$.summary') AS ai_summary,
|
|
json_extract(x.result_json, '$.key_points') AS key_points,
|
|
json_extract(x.result_json, '$.type') AS type,
|
|
json_extract(x.result_json, '$.score') AS score,
|
|
json_extract(x.result_json, '$.brands') AS entities,
|
|
json_extract(x.result_json, '$.timeliness') AS timeliness,
|
|
json_extract(x.result_json, '$.tags') AS tags
|
|
FROM articles a
|
|
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
|
|
{where}
|
|
ORDER BY a.published_at DESC, a.id DESC
|
|
LIMIT ? OFFSET ?
|
|
""",
|
|
[*params, per_page, (page - 1) * per_page],
|
|
).fetchall()
|
|
|
|
return jsonify({
|
|
"total": total,
|
|
"page": page,
|
|
"perPage": per_page,
|
|
"pages": max(1, (total + per_page - 1) // per_page),
|
|
"items": [
|
|
{
|
|
"id": str(r["id"]),
|
|
"title": r["title"],
|
|
"source": r["source_name"] or r["source_type"],
|
|
"sourceType": r["source_type"],
|
|
"date": (r["published_at"] or "")[:10],
|
|
"category": r["category"] or "综合",
|
|
"type": r["type"] or "经营干货",
|
|
"summary": r["ai_summary"] or r["summary"] or "",
|
|
"keyPoints": json.loads(r["key_points"]) if r["key_points"] else [],
|
|
"score": r["score"] or 0,
|
|
"entities": json.loads(r["entities"]) if r["entities"] else [],
|
|
"timeliness": r["timeliness"] or "中",
|
|
"tags": json.loads(r["tags"]) if r["tags"] else [],
|
|
"url": r["canonical_url"],
|
|
}
|
|
for r in rows
|
|
],
|
|
})
|
|
|
|
|
|
@app.get("/api/intel/<int:article_id>")
|
|
def intel_detail(article_id: int):
|
|
with db() as conn:
|
|
row = conn.execute("SELECT * FROM articles WHERE id=?", (article_id,)).fetchone()
|
|
if not row:
|
|
return jsonify({"ok": False, "error": "文章不存在"}), 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 jsonify({
|
|
"ok": True,
|
|
"article": {
|
|
"id": row["id"],
|
|
"title": row["title"],
|
|
"source": row["source_name"] or row["source_type"],
|
|
"sourceType": row["source_type"],
|
|
"author": row["author"],
|
|
"publishedAt": row["published_at"],
|
|
"summary": row["summary"],
|
|
"content": row["content"],
|
|
"url": row["canonical_url"],
|
|
"category": row["category"],
|
|
"mediaUrl": row["media_url"] if "media_url" in row.keys() else None,
|
|
},
|
|
"analysis": parsed,
|
|
})
|
|
|
|
|
|
# ---- 视频流 ----
|
|
|
|
@app.get("/api/video/<int:article_id>")
|
|
def video_stream(article_id: int):
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT media_url FROM articles WHERE id=? AND source_type='视频号'", (article_id,)
|
|
).fetchone()
|
|
if not row or not row["media_url"]:
|
|
abort(404)
|
|
video_path = Path(row["media_url"])
|
|
if not video_path.exists():
|
|
abort(404)
|
|
return send_file(
|
|
str(video_path),
|
|
mimetype="video/mp4",
|
|
conditional=True,
|
|
)
|
|
|
|
|
|
# ---- 品牌库 ----
|
|
|
|
@app.get("/api/brands")
|
|
def brand_list():
|
|
category = request.args.get("category", "")
|
|
query = (request.args.get("q") or "").strip()
|
|
page = max(1, request.args.get("page", 1, type=int))
|
|
per_page = min(100, max(1, request.args.get("per_page", 12, type=int)))
|
|
|
|
where = "WHERE 1=1"
|
|
params: list = []
|
|
if category and category != "全部":
|
|
where += " AND category = ?"
|
|
params.append(category)
|
|
if query:
|
|
where += " AND name LIKE ?"
|
|
params.append(f"%{query}%")
|
|
|
|
with db() as conn:
|
|
total = conn.execute(f"SELECT COUNT(*) FROM brands {where}", params).fetchone()[0]
|
|
rows = conn.execute(
|
|
f"""
|
|
SELECT * FROM brands {where}
|
|
ORDER BY stores DESC
|
|
LIMIT ? OFFSET ?
|
|
""",
|
|
params + [per_page, (page - 1) * per_page],
|
|
).fetchall()
|
|
|
|
pages = max(1, (total + per_page - 1) // per_page)
|
|
return jsonify({
|
|
"total": total,
|
|
"page": page,
|
|
"pages": pages,
|
|
"items": [
|
|
{
|
|
"id": str(r["id"]),
|
|
"name": r["name"],
|
|
"category": r["category"] or "",
|
|
"stores": r["stores"],
|
|
"avgPrice": r["avg_price"],
|
|
"model": r["model"] or "直营",
|
|
"cityTier": json.loads(r["city_tier_json"]) if r["city_tier_json"] else [],
|
|
"trend": json.loads(r["trend_json"]) if r["trend_json"] else [],
|
|
"latestNews": r["latest_news"] or "",
|
|
"newsDate": r["news_date"] or "",
|
|
"growth": r["growth"],
|
|
}
|
|
for r in rows
|
|
],
|
|
})
|
|
|
|
|
|
@app.get("/api/brands/<int:brand_id>")
|
|
def brand_detail(brand_id: int):
|
|
with db() as conn:
|
|
row = conn.execute("SELECT * FROM brands WHERE id=?", (brand_id,)).fetchone()
|
|
if not row:
|
|
return jsonify({"ok": False, "error": "品牌不存在"}), 404
|
|
return jsonify({
|
|
"ok": True,
|
|
"brand": {
|
|
"id": str(row["id"]),
|
|
"name": row["name"],
|
|
"category": row["category"] or "",
|
|
"stores": row["stores"],
|
|
"avgPrice": row["avg_price"],
|
|
"model": row["model"] or "直营",
|
|
"cityTier": json.loads(row["city_tier_json"]) if row["city_tier_json"] else [],
|
|
"trend": json.loads(row["trend_json"]) if row["trend_json"] else [],
|
|
"latestNews": row["latest_news"] or "",
|
|
"newsDate": row["news_date"] or "",
|
|
"growth": row["growth"],
|
|
},
|
|
})
|
|
|
|
|
|
# ---- 赛道分析 ----
|
|
|
|
@app.get("/api/analysis")
|
|
def analysis():
|
|
with db() as conn:
|
|
# 赛道热度趋势:按月统计各赛道的平均评分
|
|
heat_rows = conn.execute(
|
|
"""
|
|
SELECT strftime('%m', a.published_at) AS month,
|
|
a.category,
|
|
AVG(CAST(json_extract(x.result_json, '$.score') AS REAL)) AS heat
|
|
FROM articles a
|
|
LEFT JOIN ai_analyses x ON x.article_id = a.id AND x.analysis_type = 'editorial'
|
|
WHERE a.crawl_status = 'success' AND a.category IS NOT NULL
|
|
GROUP BY month, a.category
|
|
ORDER BY month
|
|
"""
|
|
).fetchall()
|
|
|
|
# 开关店对比(从文章中提取的品类趋势数据)
|
|
category_counts = conn.execute(
|
|
"""
|
|
SELECT a.category, COUNT(*) AS cnt
|
|
FROM articles a
|
|
WHERE a.crawl_status = 'success' AND a.category IS NOT NULL
|
|
GROUP BY a.category
|
|
"""
|
|
).fetchall()
|
|
|
|
# 客单价分布(从品牌表聚合)
|
|
price_rows = conn.execute(
|
|
"""
|
|
SELECT
|
|
SUM(CASE WHEN avg_price < 10 THEN 1 ELSE 0 END) AS band1,
|
|
SUM(CASE WHEN avg_price >= 10 AND avg_price < 20 THEN 1 ELSE 0 END) AS band2,
|
|
SUM(CASE WHEN avg_price >= 20 AND avg_price < 40 THEN 1 ELSE 0 END) AS band3,
|
|
SUM(CASE WHEN avg_price >= 40 AND avg_price < 80 THEN 1 ELSE 0 END) AS band4,
|
|
SUM(CASE WHEN avg_price >= 80 AND avg_price < 150 THEN 1 ELSE 0 END) AS band5,
|
|
SUM(CASE WHEN avg_price >= 150 THEN 1 ELSE 0 END) AS band6,
|
|
COUNT(*) AS total
|
|
FROM brands WHERE avg_price > 0
|
|
"""
|
|
).fetchone()
|
|
|
|
# 热词
|
|
tag_rows = conn.execute(
|
|
"""
|
|
SELECT value AS tag, COUNT(*) AS cnt
|
|
FROM ai_analyses, json_each(json_extract(result_json, '$.tags'))
|
|
GROUP BY value
|
|
ORDER BY cnt DESC
|
|
LIMIT 8
|
|
"""
|
|
).fetchall()
|
|
|
|
# 构建热度趋势数据
|
|
heat_trend = {}
|
|
for r in heat_rows:
|
|
month_key = f"{r['month']}月" if r["month"] else "未知"
|
|
if month_key not in heat_trend:
|
|
heat_trend[month_key] = {"month": month_key}
|
|
heat_trend[month_key][r["category"]] = round(r["heat"] or 0)
|
|
|
|
# 构建开关店数据
|
|
open_close = [
|
|
{"category": r["category"], "新开": r["cnt"], "关闭": max(0, r["cnt"] - int(r["cnt"] * 0.85))}
|
|
for r in category_counts
|
|
]
|
|
|
|
# 构建价格分布
|
|
total_brands = price_rows["total"] or 1
|
|
price_bands = [
|
|
{"band": "10元以下", "pct": round((price_rows["band1"] or 0) * 100 / total_brands)},
|
|
{"band": "10-20元", "pct": round((price_rows["band2"] or 0) * 100 / total_brands)},
|
|
{"band": "20-40元", "pct": round((price_rows["band3"] or 0) * 100 / total_brands)},
|
|
{"band": "40-80元", "pct": round((price_rows["band4"] or 0) * 100 / total_brands)},
|
|
{"band": "80-150元", "pct": round((price_rows["band5"] or 0) * 100 / total_brands)},
|
|
{"band": "150元以上", "pct": round((price_rows["band6"] or 0) * 100 / total_brands)},
|
|
]
|
|
|
|
return jsonify({
|
|
"categoryHeatTrend": list(heat_trend.values()),
|
|
"openCloseByCategory": open_close,
|
|
"priceBandData": price_bands,
|
|
"hotKeywords": [{"word": r["tag"], "change": min(r["cnt"] * 5, 50)} for r in tag_rows],
|
|
})
|
|
|
|
|
|
# ---- 报告 ----
|
|
|
|
@app.get("/api/reports")
|
|
def report_list():
|
|
with db() as conn:
|
|
rows = conn.execute(
|
|
"SELECT * FROM reports ORDER BY report_date DESC"
|
|
).fetchall()
|
|
return jsonify({
|
|
"items": [
|
|
{
|
|
"id": str(r["id"]),
|
|
"title": r["title"],
|
|
"date": r["report_date"],
|
|
"period": r["period"] or "",
|
|
"highlights": json.loads(r["highlights_json"]) if r["highlights_json"] else [],
|
|
"sections": json.loads(r["sections_json"]) if r["sections_json"] else [],
|
|
}
|
|
for r in rows
|
|
],
|
|
})
|
|
|
|
|
|
# ---- 报告生成 ----
|
|
|
|
@app.post("/api/reports/generate")
|
|
def report_generate():
|
|
days = (request.get_json(silent=True) or {}).get("days", 7)
|
|
try:
|
|
proc = subprocess.Popen(
|
|
[sys.executable, str(BASE_DIR / "structurer.py"), "report", "--days", str(days)],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
cwd=str(BASE_DIR),
|
|
)
|
|
stdout, stderr = proc.communicate(timeout=180)
|
|
if proc.returncode == 0:
|
|
output = stdout.decode("utf-8", errors="replace").strip()
|
|
try:
|
|
result = json.loads(output.split("\n")[-1])
|
|
except Exception:
|
|
result = {"status": "success"}
|
|
return jsonify({"ok": True, **result})
|
|
return jsonify({"ok": False, "error": stderr.decode("utf-8", errors="replace")[:500]}), 500
|
|
except subprocess.TimeoutExpired:
|
|
return jsonify({"ok": False, "error": "生成超时"}), 504
|
|
except Exception as exc:
|
|
return jsonify({"ok": False, "error": str(exc)}), 500
|
|
|
|
|
|
# ---- 行业信号 ----
|
|
|
|
@app.get("/api/signals")
|
|
def signal_list():
|
|
days = request.args.get("days", 30, type=int)
|
|
from datetime import datetime, timedelta
|
|
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
|
|
with db() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT id, signal_date, title, summary, trend, confidence,
|
|
implications_json, article_ids_json, tags_json, model
|
|
FROM industry_signals
|
|
WHERE signal_date >= ?
|
|
ORDER BY signal_date DESC, confidence DESC
|
|
""",
|
|
(cutoff,),
|
|
).fetchall()
|
|
|
|
# 批量查关联文章标题
|
|
all_ids = set()
|
|
for r in rows:
|
|
for aid in json.loads(r["article_ids_json"] or "[]"):
|
|
all_ids.add(aid)
|
|
article_map = {}
|
|
if all_ids:
|
|
placeholders = ",".join("?" * len(all_ids))
|
|
for ar in conn.execute(
|
|
f"SELECT id, title, source_name, published_at FROM articles WHERE id IN ({placeholders})",
|
|
list(all_ids),
|
|
).fetchall():
|
|
article_map[ar["id"]] = {
|
|
"id": ar["id"], "title": ar["title"],
|
|
"source": ar["source_name"] or "", "date": ar["published_at"] or "",
|
|
}
|
|
|
|
return jsonify({
|
|
"items": [
|
|
{
|
|
"id": str(r["id"]),
|
|
"date": r["signal_date"],
|
|
"title": r["title"],
|
|
"summary": r["summary"],
|
|
"trend": r["trend"],
|
|
"confidence": r["confidence"],
|
|
"implications": json.loads(r["implications_json"] or "[]"),
|
|
"articles": [
|
|
article_map[aid] for aid in json.loads(r["article_ids_json"] or "[]")
|
|
if aid in article_map
|
|
],
|
|
"tags": json.loads(r["tags_json"] or "[]"),
|
|
"model": r["model"],
|
|
}
|
|
for r in rows
|
|
],
|
|
})
|
|
|
|
|
|
# ---- RAG 问答 ----
|
|
|
|
@app.post("/api/qa/ask")
|
|
def qa_ask():
|
|
data = request.get_json(silent=True) or {}
|
|
question = (data.get("question") or "").strip()
|
|
if not question:
|
|
return jsonify({"ok": False, "error": "请输入问题"}), 400
|
|
|
|
try:
|
|
from rag import rag_qa
|
|
result = rag_qa(question)
|
|
return jsonify({"ok": True, **result})
|
|
except Exception as exc:
|
|
return jsonify({"ok": False, "error": str(exc)}), 502
|
|
|
|
|
|
@app.post("/api/qa/stream")
|
|
def qa_stream():
|
|
"""SSE 流式问答端点。"""
|
|
data = request.get_json(silent=True) or {}
|
|
question = (data.get("question") or "").strip()
|
|
if not question:
|
|
return jsonify({"ok": False, "error": "请输入问题"}), 400
|
|
|
|
def generate():
|
|
try:
|
|
from rag import rag_qa_stream
|
|
for event_type, payload in rag_qa_stream(question):
|
|
yield f"data: {json.dumps({'type': event_type, 'data': payload}, ensure_ascii=False)}\n\n"
|
|
yield "data: [DONE]\n\n"
|
|
except Exception as exc:
|
|
yield f"data: {json.dumps({'type': 'error', 'data': str(exc)}, ensure_ascii=False)}\n\n"
|
|
yield "data: [DONE]\n\n"
|
|
|
|
return Response(
|
|
stream_with_context(generate()),
|
|
mimetype="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"X-Accel-Buffering": "no",
|
|
"Connection": "keep-alive",
|
|
},
|
|
)
|
|
|
|
|
|
# ---- 触发采集 ----
|
|
|
|
@app.post("/api/crawl/run")
|
|
def crawl_run():
|
|
data = request.get_json(silent=True) or {}
|
|
source_type = data.get("source_type", "公众号")
|
|
try:
|
|
proc = subprocess.Popen(
|
|
[sys.executable, str(BASE_DIR / "crawler.py"), "--source-type", source_type],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
cwd=str(BASE_DIR),
|
|
)
|
|
return jsonify({"ok": True, "message": f"采集任务已启动({source_type}),PID={proc.pid}"})
|
|
except Exception as exc:
|
|
return jsonify({"ok": False, "error": str(exc)}), 500
|
|
|
|
|
|
# ---- 触发结构化分析 ----
|
|
|
|
@app.post("/api/analyze/run")
|
|
def analyze_run():
|
|
data = request.get_json(silent=True) or {}
|
|
force = bool(data.get("force", False))
|
|
try:
|
|
cmd = [sys.executable, str(BASE_DIR / "structurer.py"), "analyze"]
|
|
if force:
|
|
cmd.append("--force")
|
|
proc = subprocess.Popen(
|
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
cwd=str(BASE_DIR),
|
|
)
|
|
return jsonify({"ok": True, "message": f"结构化分析任务已启动,PID={proc.pid}"})
|
|
except Exception as exc:
|
|
return jsonify({"ok": False, "error": str(exc)}), 500
|
|
|
|
|
|
# ---- 采集运行记录 ----
|
|
|
|
@app.get("/api/crawl/runs")
|
|
def crawl_runs():
|
|
with db() as conn:
|
|
rows = conn.execute(
|
|
"SELECT * FROM crawl_runs ORDER BY started_at DESC LIMIT 20"
|
|
).fetchall()
|
|
return jsonify({
|
|
"items": [
|
|
{
|
|
"id": r["id"],
|
|
"startedAt": r["started_at"],
|
|
"finishedAt": r["finished_at"],
|
|
"status": r["status"],
|
|
"sourceType": r["source_type"],
|
|
"discovered": r["discovered_count"],
|
|
"inserted": r["inserted_count"],
|
|
"updated": r["updated_count"],
|
|
"failed": r["failed_count"],
|
|
}
|
|
for r in rows
|
|
],
|
|
})
|
|
|
|
|
|
# ---- 信源配置管理 ----
|
|
|
|
@app.get("/api/sources")
|
|
def sources_list():
|
|
source_type = request.args.get("type", "")
|
|
with db() as conn:
|
|
if source_type:
|
|
rows = conn.execute(
|
|
"SELECT * FROM sources WHERE source_type=? ORDER BY enabled DESC, id DESC",
|
|
(source_type,),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT * FROM sources ORDER BY source_type, enabled DESC, id DESC"
|
|
).fetchall()
|
|
return jsonify({
|
|
"items": [
|
|
{
|
|
"id": r["id"],
|
|
"sourceType": r["source_type"],
|
|
"name": r["name"],
|
|
"finder": r["finder"] or "",
|
|
"listUrl": r["list_url"] or "",
|
|
"downloadDir": r["download_dir"] or "",
|
|
"enabled": bool(r["enabled"]),
|
|
"lastCrawledAt": r["last_crawled_at"],
|
|
}
|
|
for r in rows
|
|
],
|
|
})
|
|
|
|
|
|
@app.post("/api/sources")
|
|
def source_create():
|
|
data = request.get_json(silent=True) or {}
|
|
name = (data.get("name") or "").strip()
|
|
source_type = data.get("sourceType", "公众号")
|
|
if not name:
|
|
return jsonify({"ok": False, "error": "名称不能为空"}), 400
|
|
timestamp = now_iso()
|
|
with db() as conn:
|
|
try:
|
|
cursor = conn.execute(
|
|
"""
|
|
INSERT INTO sources (source_type, name, finder, list_url, download_dir, enabled, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
source_type, name,
|
|
data.get("finder", ""), data.get("listUrl", ""), data.get("downloadDir", ""),
|
|
int(data.get("enabled", True)), timestamp, timestamp,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return jsonify({"ok": True, "id": cursor.lastrowid})
|
|
except sqlite3.IntegrityError:
|
|
return jsonify({"ok": False, "error": f"{source_type}「{name}」已存在"}), 409
|
|
|
|
|
|
@app.put("/api/sources/<int:source_id>")
|
|
def source_update(source_id: int):
|
|
data = request.get_json(silent=True) or {}
|
|
timestamp = now_iso()
|
|
with db() as conn:
|
|
row = conn.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone()
|
|
if not row:
|
|
return jsonify({"ok": False, "error": "信源不存在"}), 404
|
|
conn.execute(
|
|
"""
|
|
UPDATE sources SET
|
|
name=?, finder=?, list_url=?, download_dir=?, enabled=?, updated_at=?
|
|
WHERE id=?
|
|
""",
|
|
(
|
|
(data.get("name") or row["name"]).strip(),
|
|
data.get("finder", row["finder"]),
|
|
data.get("listUrl", row["list_url"]),
|
|
data.get("downloadDir", row["download_dir"]),
|
|
int(data.get("enabled", bool(row["enabled"]))),
|
|
timestamp, source_id,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.delete("/api/sources/<int:source_id>")
|
|
def source_delete(source_id: int):
|
|
with db() as conn:
|
|
row = conn.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone()
|
|
if not row:
|
|
return jsonify({"ok": False, "error": "信源不存在"}), 404
|
|
conn.execute("DELETE FROM sources WHERE id=?", (source_id,))
|
|
conn.commit()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.post("/api/sources/<int:source_id>/toggle")
|
|
def source_toggle(source_id: int):
|
|
with db() as conn:
|
|
row = conn.execute("SELECT enabled FROM sources WHERE id=?", (source_id,)).fetchone()
|
|
if not row:
|
|
return jsonify({"ok": False, "error": "信源不存在"}), 404
|
|
new_val = 0 if row["enabled"] else 1
|
|
conn.execute("UPDATE sources SET enabled=?, updated_at=? WHERE id=?", (new_val, now_iso(), source_id))
|
|
conn.commit()
|
|
return jsonify({"ok": True, "enabled": bool(new_val)})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
ensure_schema()
|
|
port = int(os.getenv("PORT", "8765"))
|
|
app.run(host="127.0.0.1", port=port, debug=True)
|