# -*- coding: utf-8 -*- """ 素材库系统 (Material Library) - 项目: 分类/标签/描述管理 - 素材: 上传文件(文字/文档/图片/视频/音频) + 直接粘贴文本 - 文本抽取: txt/md/pdf/docx/pptx/csv/srt/json/xml/html - 大模型分析: DeepSeek 提取摘要/要点/关键词/标签/归类; 图片走 doubao 视觉 - 索引: SQLite FTS5 (jieba 分词) 全文检索 """ import os import re import io import json import uuid import shutil import threading import datetime import sqlite3 from pathlib import Path import requests import jieba from flask import Flask, request, jsonify, send_file, abort, g # ---------------- 基础配置 ---------------- BASE_DIR = Path(__file__).resolve().parent DATA_DIR = BASE_DIR / "data" UPLOAD_DIR = DATA_DIR / "uploads" DB_PATH = DATA_DIR / "material.db" STATIC_DIR = BASE_DIR / "static" os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(STATIC_DIR, exist_ok=True) MAX_FILE_MB = 500 # 单文件上限 MAX_TEXT_LEN = 30000 # 抽取文本送入 LLM 的最大长度 # ---------------- 大模型配置 ---------------- LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.deepseek.com") LLM_API_KEY = os.environ.get("LLM_API_KEY", "sk-edb9df58ff574f8c98df1cd6a425e97c") LLM_MODEL = os.environ.get("LLM_MODEL", "deepseek-v4-flash") VISION_BASE_URL = os.environ.get("VISION_BASE_URL", "https://ark.cn-beijing.volces.com/api/plan/v3") VISION_API_KEY = os.environ.get("VISION_API_KEY", "ark-2b06dc9d-8878-4c6e-b201-f422376e79cb-246d9") VISION_MODEL = os.environ.get("VISION_MODEL", "doubao-seed-evolving") app = Flask(__name__, static_folder=str(STATIC_DIR), static_url_path="/static") app.config["MAX_CONTENT_LENGTH"] = MAX_FILE_MB * 1024 * 1024 _lock = threading.RLock() # 允许的文件扩展名 -> 素材类型 TYPE_BY_EXT = { ".txt": "text", ".md": "text", ".markdown": "text", ".log": "text", ".srt": "text", ".csv": "text", ".json": "text", ".xml": "text", ".html": "text", ".htm": "text", ".pdf": "doc", ".docx": "doc", ".doc": "doc", ".pptx": "doc", ".ppt": "doc", ".jpg": "image", ".jpeg": "image", ".png": "image", ".gif": "image", ".bmp": "image", ".webp": "image", ".tif": "image", ".tiff": "image", ".svg": "image", ".ico": "image", ".mp4": "video", ".mov": "video", ".avi": "video", ".mkv": "video", ".flv": "video", ".wmv": "video", ".webm": "video", ".m4v": "video", ".ts": "video", ".mp3": "audio", ".wav": "audio", ".flac": "audio", ".aac": "audio", ".ogg": "audio", ".m4a": "audio", ".wma": "audio", ".amr": "audio", } TEXT_EXTS = {".txt", ".md", ".markdown", ".log", ".srt", ".csv", ".json", ".xml", ".html", ".htm"} DOC_EXTS = {".pdf", ".docx", ".pptx"} IMAGE_EXTS = {k for k, v in TYPE_BY_EXT.items() if v == "image"} VIDEO_EXTS = {k for k, v in TYPE_BY_EXT.items() if v == "video"} AUDIO_EXTS = {k for k, v in TYPE_BY_EXT.items() if v == "audio"} ALLOWED_EXT = set(TYPE_BY_EXT.keys()) # ---------------- 数据库 ---------------- def get_db(): if "db" not in g: g.db = sqlite3.connect(str(DB_PATH)) g.db.row_factory = sqlite3.Row g.db.execute("PRAGMA journal_mode=WAL") g.db.execute("PRAGMA foreign_keys=ON") return g.db def close_db(e=None): db = g.pop("db", None) if db is not None: db.close() def init_db(): db = sqlite3.connect(str(DB_PATH)) db.executescript(""" CREATE TABLE IF NOT EXISTS projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT DEFAULT '', category TEXT DEFAULT '未分类', tags TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now','localtime')), updated_at TEXT DEFAULT (datetime('now','localtime')) ); CREATE TABLE IF NOT EXISTS materials ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL, name TEXT NOT NULL, mtype TEXT DEFAULT 'text', original_name TEXT DEFAULT '', file_path TEXT DEFAULT '', file_size INTEGER DEFAULT 0, ext TEXT DEFAULT '', meta_json TEXT DEFAULT '{}', extracted_text TEXT DEFAULT '', text_status TEXT DEFAULT 'pending', status TEXT DEFAULT 'uploaded', created_at TEXT DEFAULT (datetime('now','localtime')) ); CREATE TABLE IF NOT EXISTS analyses ( id INTEGER PRIMARY KEY AUTOINCREMENT, material_id INTEGER, project_id INTEGER, scope TEXT DEFAULT 'material', summary TEXT DEFAULT '', key_points TEXT DEFAULT '[]', keywords TEXT DEFAULT '[]', tags TEXT DEFAULT '[]', category TEXT DEFAULT '', detail TEXT DEFAULT '{}', model TEXT DEFAULT '', created_at TEXT DEFAULT (datetime('now','localtime')) ); CREATE INDEX IF NOT EXISTS idx_mat_project ON materials(project_id); CREATE INDEX IF NOT EXISTS idx_ana_project ON analyses(project_id); CREATE VIRTUAL TABLE IF NOT EXISTS materials_fts USING fts5(term); """) db.commit() db.close() def _ts(): return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") def _tokenize(text): return " ".join(t for t in jieba.cut(text or "") if t.strip()) def _index_material(db, mid, name, text, summary="", keywords=""): term = " ".join(filter(None, [_tokenize(name), _tokenize(text), _tokenize(summary), _tokenize(keywords)])) db.execute("DELETE FROM materials_fts WHERE rowid=?", (mid,)) if term.strip(): db.execute("INSERT INTO materials_fts(rowid, term) VALUES (?,?)", (mid, term)) # ---------------- 文本抽取 ---------------- def _read_plain(path): """读取纯文本,尝试多种编码""" raw = path.read_bytes() for enc in ("utf-8", "utf-8-sig", "gb18030", "big5", "latin-1"): try: return raw.decode(enc) except Exception: continue return raw.decode("utf-8", errors="ignore") def _extract_docx(path): import docx d = docx.Document(str(path)) parts = [p.text for p in d.paragraphs if p.text.strip()] for tbl in d.tables: for row in tbl.rows: cells = [c.text.strip() for c in row.cells if c.text.strip()] if cells: parts.append(" | ".join(cells)) return "\n".join(parts) def _extract_pptx(path): import zipfile import xml.etree.ElementTree as ET ns = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"} parts = [] with zipfile.ZipFile(str(path)) as z: slides = sorted([n for n in z.namelist() if re.match(r"ppt/slides/slide\d+\.xml$", n)], key=lambda x: int(re.search(r"\d+", x.split("/")[-1]).group())) for s in slides: try: root = ET.fromstring(z.read(s)) texts = [t.text for t in root.iter("{http://schemas.openxmlformats.org/drawingml/2006/main}t") if t.text and t.text.strip()] if texts: parts.append(" ".join(texts)) except Exception: continue return "\n".join(parts) def _extract_pdf(path): import pypdf reader = pypdf.PdfReader(str(path)) parts = [] for i, page in enumerate(reader.pages[:200]): try: t = page.extract_text() or "" if t.strip(): parts.append(t) except Exception: continue return "\n".join(parts) def extract_text(path, ext): """返回 (text, ok)""" try: if ext in (".pdf",): t = _extract_pdf(path) elif ext == ".docx": t = _extract_docx(path) elif ext == ".pptx": t = _extract_pptx(path) elif ext in TEXT_EXTS: t = _read_plain(path) else: return "", False t = re.sub(r"[ \t]+", " ", t) t = re.sub(r"\n{3,}", "\n\n", t) return t.strip(), bool(t.strip()) except Exception as e: return f"<抽取失败: {e}>", False def _meta_image(path): from PIL import Image try: im = Image.open(str(path)) return {"width": im.width, "height": im.height, "format": im.format or ""} except Exception: return {} def _safe_filename(name): name = Path(name).name name = re.sub(r"[\\/:*?\"<>|\s]+", "_", name) return name[:120] or "unnamed" # ---------------- 大模型调用 ---------------- def _llm_chat(messages, model=None, temperature=0.3, max_tokens=1500): url = f"{LLM_BASE_URL.rstrip('/')}/chat/completions" payload = { "model": model or LLM_MODEL, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, "response_format": {"type": "json_object"}, } r = requests.post(url, headers={"Authorization": f"Bearer {LLM_API_KEY}"}, json=payload, timeout=120) r.raise_for_status() return r.json()["choices"][0]["message"]["content"] ANALYZE_PROMPT = """你是专业素材库分析助手。请对下面这个素材做深度分析,提取关键信息。 只输出合法 JSON,不要任何额外文字,格式如下: {{"summary":"120字以内中文摘要","key_points":["要点1","要点2"...最多6条],"keywords":["关键词1"...最多8个],"tags":["标签1"...最多5个],"category":"建议归类,从[技术文档,产品资料,研究报告,市场资讯,项目管理,图片素材,音视频素材,其他]中选择"}} 素材名称:{name} 素材类型:{mtype} {extra} 素材内容: {content} """ def _parse_json(content): try: return json.loads(content) except Exception: m = re.search(r"\{.*\}", content, re.S) if m: try: return json.loads(m.group()) except Exception: pass return {} def _vision_describe(path): """用 doubao 视觉模型描述图片""" try: import base64 b64 = base64.b64encode(Path(path).read_bytes()).decode() url = f"{VISION_BASE_URL.rstrip('/')}/chat/completions" payload = { "model": VISION_MODEL, "messages": [{ "role": "user", "content": [ {"type": "text", "text": "请用中文简洁描述这张图片的内容(80字以内),说明主体、场景、画面中的文字信息(如有)。只输出描述本身。"}, {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}, ], }], "temperature": 0.3, "max_tokens": 300, } r = requests.post(url, headers={"Authorization": f"Bearer {VISION_API_KEY}"}, json=payload, timeout=120) r.raise_for_status() return r.json()["choices"][0]["message"]["content"].strip() except Exception: return None def analyze_material(mat): """分析单个素材,返回 (analysis_dict, model, detail)""" if not isinstance(mat, dict): mat = dict(mat) detail = {} mtype = mat["mtype"] content = mat.get("extracted_text") or "" extra = "" if mtype == "image" and mat.get("file_path"): desc = _vision_describe(mat["file_path"]) if desc: extra = "图片视觉描述:\n" + desc detail["vision"] = desc if mtype == "video": extra = "视频素材(未转写音频),基于文件名与元数据分析" if mtype == "audio": extra = "音频素材(未转写语音),基于文件名与元数据分析" if mtype == "other" or not content: content = content or "(该素材无可提取文本内容)" text = content[:MAX_TEXT_LEN] prompt = ANALYZE_PROMPT.format(name=mat["name"], mtype=mtype, extra=extra, content=text) out = _llm_chat([{"role": "user", "content": prompt}]) data = _parse_json(out) detail["raw"] = out[:3000] detail["model"] = LLM_MODEL return data, LLM_MODEL, detail def _store_analysis(db, material_id, project_id, scope, data, model, detail): if scope == "material": db.execute("DELETE FROM analyses WHERE material_id=? AND scope='material'", (material_id,)) else: db.execute("DELETE FROM analyses WHERE project_id=? AND scope='project'", (project_id,)) cur = db.execute( "INSERT INTO analyses (material_id, project_id, scope, summary, key_points, keywords, tags, category, detail, model, created_at) " "VALUES (?,?,?,?,?,?,?,?,?,?,?)", (material_id, project_id, scope, (data.get("summary") or ""), json.dumps(data.get("key_points", []), ensure_ascii=False), json.dumps(data.get("keywords", []), ensure_ascii=False), json.dumps(data.get("tags", []), ensure_ascii=False), (data.get("category") or ""), json.dumps(detail, ensure_ascii=False), model, _ts())) db.commit() return cur.lastrowid # ---------------- 后台任务 ---------------- _tasks = {} def _task_key(scope, obj_id): return f"{scope}:{obj_id}" def _run_analysis(project_id, material_ids=None, scope="material"): key = _task_key(scope, project_id) _tasks[key] = {"running": True, "done": 0, "total": 0, "msg": "准备中", "error": None} db = sqlite3.connect(str(DB_PATH)) db.row_factory = sqlite3.Row try: if scope == "project": cond = "project_id=? AND (status='uploaded' OR status='error' OR status='analyzed')" mats = db.execute(f"SELECT * FROM materials WHERE {cond}", (project_id,)).fetchall() else: if material_ids: ph = ",".join("?" for _ in material_ids) mats = db.execute(f"SELECT * FROM materials WHERE project_id=? AND id IN ({ph})", (project_id, *material_ids)).fetchall() else: # 未指定 id → 分析项目下所有待分析/失败素材 mats = db.execute( "SELECT * FROM materials WHERE project_id=? AND (status='uploaded' OR status='error' OR status='analyzed')", (project_id,)).fetchall() _tasks[key]["total"] = len(mats) for i, mat in enumerate(mats, 1): mid = mat["id"] try: db.execute("UPDATE materials SET status='analyzing' WHERE id=?", (mid,)) db.commit() data, model, detail = analyze_material(mat) aid = _store_analysis(db, mid, project_id, "material", data, model, detail) # 回写关键词作为标签候选 + 更新索引 keywords = data.get("keywords", []) kw = ",".join(keywords[:8]) if isinstance(keywords, list) else "" db.execute("UPDATE materials SET status='analyzed', name=name WHERE id=?", (mid,)) row = db.execute("SELECT name, extracted_text, summary FROM materials LEFT JOIN analyses ON analyses.id=? WHERE materials.id=?", (aid, mid)).fetchone() _index_material(db, mid, row["name"], row["extracted_text"] or "", row["summary"] or "", kw) db.commit() except Exception as e: db.execute("UPDATE materials SET status='error' WHERE id=?", (mid,)) db.commit() _tasks[key]["error"] = f"{mat['name']}: {e}" _tasks[key]["done"] = i _tasks[key]["msg"] = f"已分析 {i}/{len(mats)}" except Exception as e: _tasks[key]["error"] = str(e) finally: _tasks[key]["running"] = False if not _tasks[key].get("error"): _tasks[key]["msg"] = "完成" db.close() def _run_project_summary(project_id): key = _task_key("summary", project_id) _tasks[key] = {"running": True, "done": 0, "total": 1, "msg": "生成中", "error": None} db = sqlite3.connect(str(DB_PATH)) db.row_factory = sqlite3.Row try: proj = db.execute("SELECT * FROM projects WHERE id=?", (project_id,)).fetchone() mats = db.execute("SELECT * FROM materials WHERE project_id=?", (project_id,)).fetchall() # 汇总:已有素材级分析的摘要 + 原始文本 parts = [] for m in mats[:15]: ana = db.execute("SELECT * FROM analyses WHERE material_id=? AND scope='material' ORDER BY id DESC LIMIT 1", (m["id"],)).fetchone() if ana and ana["summary"]: parts.append(f"- {m['name']}: {ana['summary']}") else: t = (m["extracted_text"] or "")[:300] parts.append(f"- {m['name']}: {t}") content = "\n".join(parts) or "(项目暂无素材)" prompt = f"""你是项目管理助理。基于以下素材库项目的资料汇总,生成项目级分析报告。只输出合法 JSON: {{"summary":"整个项目内容的总摘要(150字以内)","key_points":["项目关键发现"...最多8条],"keywords":["项目级关键词"...最多10个],"tags":["项目标签"...最多5个],"category":"建议项目类别"}} 项目名称:{proj['name']} 项目描述:{proj['description'] or '无'} 素材数量:{len(mats)} 资料汇总: {content[:MAX_TEXT_LEN]} """ out = _llm_chat([{"role": "user", "content": prompt}]) data = _parse_json(out) _store_analysis(db, None, project_id, "project", data, LLM_MODEL, {"model": LLM_MODEL, "raw": out[:3000]}) db.execute("UPDATE projects SET updated_at=? WHERE id=?", (_ts(), project_id)) db.commit() _tasks[key]["done"] = 1 _tasks[key]["msg"] = "完成" except Exception as e: _tasks[key]["error"] = str(e) finally: _tasks[key]["running"] = False db.close() # ---------------- 辅助 ---------------- def project_json(row, db): n = db.execute("SELECT COUNT(*) FROM materials WHERE project_id=?", (row["id"],)).fetchone()[0] na = db.execute("SELECT COUNT(*) FROM materials WHERE project_id=? AND status='analyzed'", (row["id"],)).fetchone()[0] ana = db.execute("SELECT * FROM analyses WHERE project_id=? AND scope='project' ORDER BY id DESC LIMIT 1", (row["id"],)).fetchone() ana_d = None if ana: ana_d = dict(ana) ana_d["key_points"] = json.loads(ana_d["key_points"] or "[]") ana_d["keywords"] = json.loads(ana_d["keywords"] or "[]") ana_d["tags"] = json.loads(ana_d["tags"] or "[]") ana_d["detail"] = json.loads(ana_d["detail"] or "{}") return { "id": row["id"], "name": row["name"], "description": row["description"], "category": row["category"], "tags": row["tags"], "created_at": row["created_at"], "updated_at": row["updated_at"], "material_count": n, "analyzed_count": na, "analysis": ana_d, } def material_json(row, db): d = dict(row) d["meta"] = json.loads(row["meta_json"] or "{}") ana = db.execute("SELECT * FROM analyses WHERE material_id=? AND scope='material' ORDER BY id DESC LIMIT 1", (row["id"],)).fetchone() d["analysis"] = dict(ana) if ana else None if d["analysis"]: d["analysis"]["key_points"] = json.loads(d["analysis"]["key_points"] or "[]") d["analysis"]["keywords"] = json.loads(d["analysis"]["keywords"] or "[]") d["analysis"]["tags"] = json.loads(d["analysis"]["tags"] or "[]") d["analysis"]["detail"] = json.loads(d["analysis"]["detail"] or "{}") return d # ---------------- 路由:页面 ---------------- @app.route("/") def index(): return send_file(str(STATIC_DIR / "index.html")) @app.route("/files/") def serve_file(mid): db = get_db() row = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone() if not row or not row["file_path"]: abort(404) path = Path(row["file_path"]) if not path.exists(): abort(404) ext = (row["ext"] or "").lower() if ext in IMAGE_EXTS: return send_file(str(path), mimetype="image/*", download_name=row["name"]) if ext in VIDEO_EXTS: return send_file(str(path), mimetype="video/mp4", download_name=row["name"]) if ext in AUDIO_EXTS: return send_file(str(path), mimetype="audio/mpeg", download_name=row["name"]) return send_file(str(path), as_attachment=True, download_name=row["original_name"] or row["name"]) # ---------------- 路由:项目 ---------------- @app.get("/api/projects") def list_projects(): db = get_db() q = (request.args.get("q") or "").strip() category = (request.args.get("category") or "").strip() tag = (request.args.get("tag") or "").strip() sql = "SELECT * FROM projects WHERE 1=1" args = [] if q: sql += " AND (name LIKE ? OR description LIKE ? OR tags LIKE ?)" like = f"%{q}%" args += [like, like, like] if category: sql += " AND category=?" args.append(category) if tag: sql += " AND tags LIKE ?" args.append(f"%{tag}%") sql += " ORDER BY updated_at DESC, id DESC" rows = db.execute(sql, args).fetchall() return jsonify([project_json(r, db) for r in rows]) @app.post("/api/projects") def create_project(): d = request.get_json(force=True, silent=True) or {} name = (d.get("name") or "").strip() if not name: return jsonify({"error": "项目名称不能为空"}), 400 db = get_db() cur = db.execute("INSERT INTO projects (name, description, category, tags, updated_at) VALUES (?,?,?,?,?)", (name, (d.get("description") or "").strip(), (d.get("category") or "未分类").strip(), (d.get("tags") or "").strip(), _ts())) db.commit() row = db.execute("SELECT * FROM projects WHERE id=?", (cur.lastrowid,)).fetchone() return jsonify(project_json(row, db)) @app.get("/api/projects/") def get_project(pid): db = get_db() row = db.execute("SELECT * FROM projects WHERE id=?", (pid,)).fetchone() if not row: return jsonify({"error": "项目不存在"}), 404 p = project_json(row, db) mats = db.execute("SELECT * FROM materials WHERE project_id=? ORDER BY id DESC", (pid,)).fetchall() p["materials"] = [material_json(m, db) for m in mats] return jsonify(p) @app.put("/api/projects/") def update_project(pid): d = request.get_json(force=True, silent=True) or {} db = get_db() row = db.execute("SELECT * FROM projects WHERE id=?", (pid,)).fetchone() if not row: return jsonify({"error": "项目不存在"}), 404 name = (d.get("name") or row["name"]).strip() db.execute("UPDATE projects SET name=?, description=?, category=?, tags=?, updated_at=? WHERE id=?", (name, (d.get("description", row["description"]) or "").strip(), (d.get("category", row["category"]) or "未分类").strip(), (d.get("tags", row["tags"]) or "").strip(), _ts(), pid)) db.commit() return jsonify(project_json(db.execute("SELECT * FROM projects WHERE id=?", (pid,)).fetchone(), db)) @app.delete("/api/projects/") def delete_project(pid): db = get_db() mats = db.execute("SELECT * FROM materials WHERE project_id=?", (pid,)).fetchall() for m in mats: if m["file_path"]: p = Path(m["file_path"]) if p.exists(): try: p.unlink() except Exception: pass db.execute("DELETE FROM materials_fts WHERE rowid=?", (m["id"],)) db.execute("DELETE FROM analyses WHERE project_id=?", (pid,)) db.execute("DELETE FROM materials WHERE project_id=?", (pid,)) db.execute("DELETE FROM projects WHERE id=?", (pid,)) db.commit() return jsonify({"ok": True}) @app.get("/api/meta") def get_meta(): db = get_db() cats = db.execute("SELECT category, COUNT(*) c FROM projects GROUP BY category ORDER BY c DESC").fetchall() all_tags = [r["tags"] for r in db.execute("SELECT tags FROM projects").fetchall() if r["tags"]] tag_counter = {} for t in all_tags: for x in [x.strip() for x in t.split(",") if x.strip()]: tag_counter[x] = tag_counter.get(x, 0) + 1 stats = { "projects": db.execute("SELECT COUNT(*) FROM projects").fetchone()[0], "materials": db.execute("SELECT COUNT(*) FROM materials").fetchone()[0], "analyzed": db.execute("SELECT COUNT(*) FROM materials WHERE status='analyzed'").fetchone()[0], } return jsonify({ "categories": [{"name": r["category"], "count": r["c"]} for r in cats], "tags": [{"name": k, "count": v} for k, v in sorted(tag_counter.items(), key=lambda x: -x[1])[:40]], "stats": stats, }) # ---------------- 路由:素材 ---------------- @app.post("/api/projects//materials/upload") def upload_materials(pid): db = get_db() if not db.execute("SELECT 1 FROM projects WHERE id=?", (pid,)).fetchone(): return jsonify({"error": "项目不存在"}), 404 files = request.files.getlist("files") results = [] for f in files: if not f or not f.filename: continue orig = f.filename ext = os.path.splitext(orig)[1].lower() safe = _safe_filename(orig) if ext not in ALLOWED_EXT: results.append({"name": orig, "ok": False, "error": f"不支持的文件类型 {ext or '(无扩展名)'}"}) continue pid_dir = UPLOAD_DIR / str(pid) pid_dir.mkdir(parents=True, exist_ok=True) uniq = uuid.uuid4().hex[:10] dest = pid_dir / f"{uniq}_{safe}" f.save(str(dest)) mtype = TYPE_BY_EXT.get(ext, "other") # 提取文本 text, ok = extract_text(dest, ext) text_status = "extracted" if ok else ("empty" if not text else "error") # 元数据 meta = {} if mtype == "image": meta = _meta_image(dest) size = dest.stat().st_size cur = db.execute( "INSERT INTO materials (project_id, name, mtype, original_name, file_path, file_size, ext, meta_json, extracted_text, text_status) " "VALUES (?,?,?,?,?,?,?,?,?,?)", (pid, safe, mtype, orig, str(dest), size, ext, json.dumps(meta, ensure_ascii=False), text, text_status)) mid = cur.lastrowid _index_material(db, mid, safe, text) db.execute("UPDATE projects SET updated_at=? WHERE id=?", (_ts(), pid)) db.commit() row = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone() results.append(material_json(row, db)) return jsonify({"results": results, "count": len(results)}) @app.post("/api/projects//materials") def add_text_material(pid): d = request.get_json(force=True, silent=True) or {} db = get_db() if not db.execute("SELECT 1 FROM projects WHERE id=?", (pid,)).fetchone(): return jsonify({"error": "项目不存在"}), 404 name = (d.get("name") or "").strip() or "未命名文本" content = (d.get("content") or "") cur = db.execute( "INSERT INTO materials (project_id, name, mtype, original_name, file_size, meta_json, extracted_text, text_status, status) " "VALUES (?,?,?,?,?,?,?,?,?)", (pid, name, "text", "", len(content.encode("utf-8")), "{}", content, "extracted" if content.strip() else "empty", "uploaded")) mid = cur.lastrowid _index_material(db, mid, name, content) db.execute("UPDATE projects SET updated_at=? WHERE id=?", (_ts(), pid)) db.commit() row = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone() return jsonify(material_json(row, db)) @app.get("/api/materials/") def get_material(mid): db = get_db() row = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone() if not row: return jsonify({"error": "素材不存在"}), 404 return jsonify(material_json(row, db)) @app.put("/api/materials/") def update_material(mid): d = request.get_json(force=True, silent=True) or {} db = get_db() row = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone() if not row: return jsonify({"error": "素材不存在"}), 404 name = (d.get("name") or row["name"]).strip() mtype = (d.get("mtype") or row["mtype"]).strip() db.execute("UPDATE materials SET name=?, mtype=? WHERE id=?", (name, mtype, mid)) # 更新索引 ana = db.execute("SELECT * FROM analyses WHERE material_id=? AND scope='material' ORDER BY id DESC LIMIT 1", (mid,)).fetchone() kw = ",".join(json.loads(ana["keywords"] or "[]")) if ana else "" _index_material(db, mid, name, row["extracted_text"] or "", (ana["summary"] if ana else "") or "", kw) db.execute("UPDATE projects SET updated_at=? WHERE id=?", (_ts(), row["project_id"])) db.commit() row2 = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone() return jsonify(material_json(row2, db)) @app.delete("/api/materials/") def delete_material(mid): db = get_db() row = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone() if not row: return jsonify({"error": "素材不存在"}), 404 if row["file_path"]: p = Path(row["file_path"]) if p.exists(): try: p.unlink() except Exception: pass db.execute("DELETE FROM materials_fts WHERE rowid=?", (mid,)) db.execute("DELETE FROM analyses WHERE material_id=?", (mid,)) db.execute("DELETE FROM materials WHERE id=?", (mid,)) db.execute("UPDATE projects SET updated_at=? WHERE id=?", (_ts(), row["project_id"])) db.commit() return jsonify({"ok": True}) # ---------------- 路由:分析 ---------------- @app.post("/api/materials//analyze") def analyze_one(mid): db = get_db() row = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone() if not row: return jsonify({"error": "素材不存在"}), 404 t = threading.Thread(target=_run_analysis, args=(row["project_id"], [mid]), daemon=True) t.start() return jsonify({"ok": True, "task": _task_key("material", row["project_id"])}) @app.post("/api/projects//analyze") def analyze_project(pid): db = get_db() if not db.execute("SELECT 1 FROM projects WHERE id=?", (pid,)).fetchone(): return jsonify({"error": "项目不存在"}), 404 key = _task_key("material", pid) t = threading.Thread(target=_run_analysis, args=(pid, None, "material"), daemon=True) t.start() return jsonify({"ok": True, "task": key}) @app.post("/api/projects//summary") def project_summary(pid): db = get_db() if not db.execute("SELECT 1 FROM projects WHERE id=?", (pid,)).fetchone(): return jsonify({"error": "项目不存在"}), 404 t = threading.Thread(target=_run_project_summary, args=(pid,), daemon=True) t.start() return jsonify({"ok": True, "task": _task_key("summary", pid)}) @app.get("/api/tasks/") def task_status(key): t = _tasks.get(key) if not t: return jsonify({"running": False, "done": 0, "total": 0, "msg": "无任务", "error": None}) return jsonify(t) # ---------------- 路由:搜索 ---------------- @app.get("/api/search") def search(): q = (request.args.get("q") or "").strip() if not q: return jsonify({"projects": [], "materials": []}) db = get_db() # 1) 项目 LIKE projs = db.execute("SELECT * FROM projects WHERE name LIKE ? OR description LIKE ? OR tags LIKE ? ORDER BY updated_at DESC LIMIT 10", (f"%{q}%", f"%{q}%", f"%{q}%")).fetchall() # 2) FTS 素材 materials = [] tokens = [t for t in jieba.cut(q) if t.strip()] if tokens: match = " AND ".join('"' + t + '"' for t in tokens[:6]) try: ids = [r[0] for r in db.execute("SELECT rowid FROM materials_fts WHERE materials_fts MATCH ? ORDER BY rank LIMIT 50", (match,)).fetchall()] if ids: ph = ",".join("?" for _ in ids) rows = db.execute(f"SELECT * FROM materials WHERE id IN ({ph})", ids).fetchall() by_id = {r["id"]: r for r in rows} for i in ids: if i in by_id: materials.append(by_id[i]) except Exception: materials = [] # 3) LIKE 兜底 if not materials: materials = db.execute("SELECT * FROM materials WHERE name LIKE ? OR extracted_text LIKE ? ORDER BY id DESC LIMIT 30", (f"%{q}%", f"%{q}%")).fetchall() return jsonify({ "projects": [project_json(p, db) for p in projs], "materials": [material_json(m, db) for m in materials], }) app.teardown_appcontext(close_db) if __name__ == "__main__": init_db() port = int(os.environ.get("PORT", "16091")) print(f"素材库系统启动: http://0.0.0.0:{port}") app.run(host="0.0.0.0", port=port, debug=False, threaded=True)