diff --git a/backend/app/analysis/video_stt.py b/backend/app/analysis/video_stt.py index 8c5d39e..229cf50 100644 --- a/backend/app/analysis/video_stt.py +++ b/backend/app/analysis/video_stt.py @@ -126,7 +126,9 @@ def _extract_wav_segment(video_path: str, *, start_sec: float, clip_sec: float) check=False, ) if proc.returncode != 0: - err = (proc.stderr or b"").decode("utf-8", errors="replace")[:400] + err_raw = (proc.stderr or b"").decode("utf-8", errors="replace") + # 头部多为版本信息,真实错误常在尾部;输出尾部更利于定位。 + err = err_raw[-1000:] if err_raw else "" logger.warning("VIDEO_STT: ffmpeg 片段提取失败 rc=%s %s", proc.returncode, err) return b"" with open(wav_path, "rb") as wf: @@ -274,17 +276,18 @@ async def _transcribe_single_wav( return _transcription_text_from_response(resp) -async def transcribe_video_with_whisper(video_bytes: bytes, container_suffix: str) -> str: +async def transcribe_video_with_whisper(video_bytes: bytes, container_suffix: str) -> tuple[str, str]: """ 异步:线程池提取 WAV + AsyncOpenAI Whisper 转写。 - @returns 转写文本,失败或未开启时为空字符串 + @returns (转写文本, 状态码) + status: ok | disabled | missing_config | extract_failed | api_error | empty_text """ if not _stt_enabled(): - return "" + return "", "disabled" key, base = _resolve_whisper_client_config() if not key or not base: - return "" + return "", "missing_config" model = (os.getenv("WHISPER_MODEL") or "whisper-1").strip() @@ -293,7 +296,7 @@ async def transcribe_video_with_whisper(video_bytes: bytes, container_suffix: st logger.warning( "VIDEO_STT: WAV 片段为空,未调用转写 API(检查 ffmpeg、视频是否含音轨、上方 stderr 日志)", ) - return "" + return "", "extract_failed" import httpx from openai import AsyncOpenAI @@ -336,12 +339,13 @@ async def transcribe_video_with_whisper(video_bytes: bytes, container_suffix: st merged = _join_transcript_parts(texts) if merged: logger.info("VIDEO_STT: 全片转写成功 chunks=%s total_len=%s model=%s", total, len(merged), model) + return merged, "ok" else: logger.warning("VIDEO_STT: 全片转写为空,请核对 ASR 模型与音频内容") - return merged + return "", "empty_text" except Exception as e: logger.warning("VIDEO_STT: 转写 API 失败 %s", e) - return "" + return "", "api_error" finally: await http_client.aclose() diff --git a/backend/app/api/comments_api.py b/backend/app/api/comments_api.py index ae22951..82f3016 100644 --- a/backend/app/api/comments_api.py +++ b/backend/app/api/comments_api.py @@ -4,8 +4,9 @@ """ import json import logging +import time from pydantic import BaseModel -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException from app.agents.base_agent import BaseAgent, MODEL_FAST @@ -68,8 +69,51 @@ async def generate_comments(req: GenerateCommentsRequest): result = await agent.call_llm(user_msg, max_tokens=2000) result.pop("_meta", None) + + if result.get("dimension") == "error": + issues = result.get("issues") or [] + detail = issues[0] if issues else result.get("reasoning", "评论生成失败") + # #region agent log + try: + with open("/Users/yxz/Desktop/projects/小红书黑客松/noterx/.cursor/debug-7caaea.log", "a", encoding="utf-8") as _df: + _df.write(json.dumps({ + "sessionId": "7caaea", + "runId": "post-fix", + "hypothesisId": "B", + "location": "comments_api.py:generate_comments:error", + "message": "llm error raised to client", + "data": {"detail": str(detail)[:200]}, + "timestamp": int(time.time() * 1000), + }, ensure_ascii=False) + "\n") + except Exception: + pass + # #endregion + raise HTTPException(status_code=503, detail=str(detail)) + comments = result.get("comments", []) + # #region agent log + try: + with open("/Users/yxz/Desktop/projects/小红书黑客松/noterx/.cursor/debug-7caaea.log", "a", encoding="utf-8") as _df: + _df.write(json.dumps({ + "sessionId": "7caaea", + "runId": "pre-fix", + "hypothesisId": "A,B", + "location": "comments_api.py:generate_comments", + "message": "generate_comments llm result", + "data": { + "existing_count": req.existing_count, + "title_len": len(req.title or ""), + "raw_keys": list(result.keys())[:12], + "has_error_dimension": result.get("dimension") == "error", + "comments_count": len(comments) if isinstance(comments, list) else -1, + }, + "timestamp": int(time.time() * 1000), + }, ensure_ascii=False) + "\n") + except Exception: + pass + # #endregion + formatted = [] for c in comments: if not isinstance(c, dict): diff --git a/backend/app/api/screenshot_api.py b/backend/app/api/screenshot_api.py index f3810a6..3f99e33 100644 --- a/backend/app/api/screenshot_api.py +++ b/backend/app/api/screenshot_api.py @@ -10,6 +10,7 @@ import logging import os import re +import tempfile from io import BytesIO from typing import Optional @@ -31,6 +32,11 @@ router = APIRouter() logger = logging.getLogger("noterx.screenshot") +_MIMO_BASES = ( + "https://api.xiaomimimo.com/v1", + "https://api.mimo-v2.com/v1", +) + def _env_int(name: str, default: int, *, min_v: int, max_v: int) -> int: """读取整数环境变量并夹紧到 [min_v, max_v]。""" @@ -50,6 +56,84 @@ def _env_float(name: str, default: float, *, min_v: float, max_v: float) -> floa return max(min_v, min(v, max_v)) +def _looks_like_connection_error(exc: BaseException) -> bool: + msg = str(exc).lower() + if "connection error" in msg: + return True + if "connect" in msg and "error" in msg: + return True + if "dns" in msg or "name resolution" in msg: + return True + if "timed out" in msg: + return True + n = exc.__class__.__name__.lower() + return "connection" in n or "connect" in n or "timeout" in n + + +def _humanize_connection_error(raw: object) -> str: + detail = str(raw or "").strip() + base = (os.getenv("OPENAI_BASE_URL") or "").strip() or "https://api.openai.com/v1" + return ( + "连接 AI 网关失败。请检查网络与网关配置:" + f"OPENAI_BASE_URL={base}。" + "若使用 MiMo,可尝试切换到 https://api.mimo-v2.com/v1 或 https://api.xiaomimimo.com/v1。" + + (f" 原始错误: {detail}" if detail else "") + ) + + +def _mimo_fallback_base_urls() -> list[str]: + cur = (os.getenv("OPENAI_BASE_URL") or "").strip().rstrip("/") + extra = (os.getenv("OPENAI_BASE_URL_FALLBACK") or "").strip().rstrip("/") + out: list[str] = [] + for base in (extra, *_MIMO_BASES): + if not base: + continue + if base == cur: + continue + if base in out: + continue + out.append(base) + return out + + +async def _retry_chat_with_fallback_mimo( + kwargs: dict, + *, + timeout_sec: Optional[float] = None, +) -> object | None: + """ + 当当前网关连接失败时,尝试备用 MiMo 域名重试一次请求。 + """ + if not _is_mimo_openai_compat(): + return None + key = (os.getenv("OPENAI_API_KEY") or "").strip() + if not key: + return None + + import httpx + from openai import AsyncOpenAI + + for base in _mimo_fallback_base_urls(): + http_client = httpx.AsyncClient( + proxy=None, + trust_env=False, + timeout=httpx.Timeout(120.0, connect=30.0), + ) + try: + alt = AsyncOpenAI(api_key=key, base_url=base, http_client=http_client) + if timeout_sec is not None: + resp = await asyncio.wait_for(alt.chat.completions.create(**kwargs), timeout=timeout_sec) + else: + resp = await alt.chat.completions.create(**kwargs) + logger.info("快识请求已切换备用网关成功: %s", base) + return resp + except Exception as e: + logger.warning("备用网关调用失败 %s: %s", base, e) + finally: + await http_client.aclose() + return None + + def _quick_image_max_out_tokens() -> int: """快识图片:默认与 .env.example 建议一致,避免过大 max_completion_tokens 被网关拒掉。""" return _env_int("QUICK_RECOGNIZE_MAX_COMPLETION_TOKENS", 2048, min_v=256, max_v=8192) @@ -323,6 +407,15 @@ async def _vision_call( ) except asyncio.TimeoutError: return {"error": "视觉识别超时(60s)", "slot_type": "other"} + except Exception as e: + if _looks_like_connection_error(e): + retry = await _retry_chat_with_fallback_mimo(kwargs, timeout_sec=60) + if retry is not None: + resp = retry + else: + return {"error": _humanize_connection_error(e), "slot_type": "other"} + else: + return {"error": str(e), "slot_type": "other"} raw = resp.choices[0].message.content or "" # Try multiple JSON extraction strategies clean = raw.strip() @@ -406,6 +499,54 @@ def _content_text_looks_like_video_scene_caption(text: str) -> bool: return False +def _looks_like_video_player_meta_line(line: str) -> bool: + """ + 判断是否为播放器浮层/控制条噪声(时间轴、分辨率、倍速等)。 + """ + s = str(line or "").strip() + if not s: + return True + + # 00:00/00:52, 1:23 / 10:02 等时间轴格式 + if re.fullmatch(r"\d{1,2}:\d{2}\s*/\s*\d{1,2}:\d{2}", s): + return True + # 单独时间(常见于控制条) + if re.fullmatch(r"\d{1,2}:\d{2}", s): + return True + # 分辨率/清晰度 + if re.fullmatch(r"\d{3,4}p", s, flags=re.IGNORECASE): + return True + if re.fullmatch(r"(HD|FHD|UHD|4K|2K|8K)", s, flags=re.IGNORECASE): + return True + # 倍速 + if re.fullmatch(r"\d(?:\.\d+)?x", s, flags=re.IGNORECASE): + return True + + meta_kw = ( + "播放", + "暂停", + "全屏", + "画中画", + "静音", + "音量", + "倍速", + "清晰度", + "上一集", + "下一集", + "重播", + "进度", + "拖动", + ) + if any(k in s for k in meta_kw): + return True + + # 只含数字+符号(常见控制条残片) + if re.fullmatch(r"[\d:/.\-_%\s]+", s): + return True + + return False + + def _strip_video_scene_caption_lines(text: str) -> str: """ 清除内容中的「画面描述型」旁白行,仅保留逐字字幕/口播文本。 @@ -418,7 +559,12 @@ def _strip_video_scene_caption_lines(text: str) -> str: if not lines: return "" - kept = [ln for ln in lines if not _content_text_looks_like_video_scene_caption(ln)] + kept = [ + ln + for ln in lines + if not _content_text_looks_like_video_scene_caption(ln) + and not _looks_like_video_player_meta_line(ln) + ] if kept: return "\n".join(kept).strip() return "" @@ -476,6 +622,21 @@ def _coerce_video_quick_slot_when_body_present(result: dict) -> None: result["slot_type"] = "content" +def _video_body_is_too_short_to_use(text: str) -> bool: + """ + 仅有极短钩子词时(如“注意看”),不应当作视频正文自动回填。 + """ + s = str(text or "").strip() + if not s: + return True + lines = [ln.strip() for ln in s.splitlines() if ln.strip()] + if not lines: + return True + if len(lines) == 1 and len(lines[0]) <= 8: + return True + return False + + def _quick_payload_is_empty(result: dict) -> bool: return ( not str(result.get("title", "")).strip() @@ -570,6 +731,143 @@ def _merge_stt_into_video_result(result: dict, stt: str) -> None: result["content_text"] = f"{prev}\n\n{text}".strip() +def _extract_video_text_frames( + video_bytes: bytes, + container_suffix: str, + *, + max_frames: int = 4, +) -> list[bytes]: + """ + 从视频中均匀抽取多帧,用于字幕/花字兜底提取。 + """ + try: + import cv2 + except Exception: + logger.warning("视频字幕兜底:OpenCV unavailable") + return [] + + suffix = container_suffix if container_suffix.startswith(".") else f".{container_suffix}" + temp_path = "" + out: list[bytes] = [] + try: + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f: + f.write(video_bytes) + temp_path = f.name + + cap = cv2.VideoCapture(temp_path) + if not cap.isOpened(): + cap.release() + return [] + + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0) + picks: list[int] = [] + n = max(1, min(max_frames, 8)) + if total_frames > 0: + # 避开首尾,均匀取样 + for i in range(n): + ratio = (i + 1) / (n + 1) + idx = int(total_frames * ratio) + picks.append(max(0, min(idx, total_frames - 1))) + else: + # 帧数未知时按步进读 + picks = [0, 30, 60, 90][:n] + + seen: set[int] = set() + for idx in picks: + if idx in seen: + continue + seen.add(idx) + cap.set(cv2.CAP_PROP_POS_FRAMES, idx) + ok, frame = cap.read() + if not ok or frame is None or getattr(frame, "size", 0) <= 0: + continue + enc_ok, enc = cv2.imencode(".jpg", frame) + if not enc_ok: + continue + out.append(enc.tobytes()) + cap.release() + return out + except Exception as e: + logger.warning("视频字幕兜底:抽帧失败 %s", e) + return [] + finally: + if temp_path and os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + + +def _parse_lines_from_frame_result(raw: dict) -> list[str]: + """ + 解析单帧识别结果中的文本行。 + """ + if not isinstance(raw, dict): + return [] + lines_obj = raw.get("lines") + out: list[str] = [] + if isinstance(lines_obj, list): + cands = [str(x).strip() for x in lines_obj if str(x).strip()] + else: + cands = [] + for k in ("content_text", "text", "subtitle", "summary"): + v = str(raw.get(k, "")).strip() + if v: + cands.extend([ln.strip() for ln in v.split("\n") if ln.strip()]) + + for ln in cands: + if _looks_like_video_player_meta_line(ln): + continue + if _content_text_looks_like_video_scene_caption(ln): + continue + if len(ln) <= 1: + continue + out.append(ln) + return out + + +async def _recover_video_text_from_frames( + client, + video_bytes: bytes, + container_ext: str, +) -> str: + """ + STT 不可用时,多帧提取画面字幕/花字作为正文兜底。 + """ + max_frames = _env_int("VIDEO_TEXT_FRAME_FALLBACK_FRAMES", 4, min_v=2, max_v=8) + frames = _extract_video_text_frames(video_bytes, container_ext, max_frames=max_frames) + if not frames: + return "" + + prompt = ( + "你是视频字幕提取助手。只提取画面上实际可见的字幕/花字/贴纸文字。" + "禁止场景描述,禁止输出播放器UI(时间轴、1080P、倍速、播放按钮等)。" + "只输出 JSON:{\"lines\": [\"...\", \"...\"]}" + ) + sem = asyncio.Semaphore(2) + + async def _one(img: bytes) -> list[str]: + async with sem: + res = await _vision_call( + client, + prompt, + img, + max_out_tokens=512, + image_mime="image/jpeg", + ) + return _parse_lines_from_frame_result(res if isinstance(res, dict) else {}) + + chunks = await asyncio.gather(*[_one(f) for f in frames], return_exceptions=True) + merged: list[str] = [] + for x in chunks: + if isinstance(x, Exception): + continue + for ln in x: + if ln not in merged: + merged.append(ln) + return "\n".join(merged).strip() + + async def _video_url_quick_call(client, video_url: str) -> dict: """ 通过 MiMo 视频理解(video_url content part)请求模型,返回与快识相同结构的 JSON。 @@ -605,7 +903,16 @@ async def _video_url_quick_call(client, video_url: str) -> dict: else: kwargs["max_tokens"] = out_cap - resp = await client.chat.completions.create(**kwargs) + try: + resp = await client.chat.completions.create(**kwargs) + except Exception as e: + if _looks_like_connection_error(e): + retry = await _retry_chat_with_fallback_mimo(kwargs) + if retry is None: + return {"error": _humanize_connection_error(e), "slot_type": "other"} + resp = retry + else: + return {"error": str(e), "slot_type": "other"} raw = (resp.choices[0].message.content or "").strip() if raw.startswith("```"): raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip() @@ -655,7 +962,18 @@ async def _video_url_subtitle_transcript_call(client, video_url: str) -> list[st else: kwargs["max_tokens"] = out_cap - resp = await client.chat.completions.create(**kwargs) + try: + resp = await client.chat.completions.create(**kwargs) + except Exception as e: + if _looks_like_connection_error(e): + retry = await _retry_chat_with_fallback_mimo(kwargs) + if retry is None: + logger.warning("视频专向听写连接失败: %s", _humanize_connection_error(e)) + return [] + resp = retry + else: + logger.warning("视频专向听写失败: %s", e) + return [] raw = (resp.choices[0].message.content or "").strip() if raw.startswith("```"): raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip() @@ -929,15 +1247,17 @@ async def quick_recognize_video(request: Request, file: UploadFile = File(...)): await _ocr_supplement_quick_result(client, frame_jpeg, result, ocr_cap) stt_text = "" + stt_status = "unknown" try: _stt_t = float(os.getenv("VIDEO_STT_TIMEOUT_SEC", "240")) except ValueError: _stt_t = 240.0 stt_timeout = max(30.0, min(_stt_t, 600.0)) try: - stt_text = await asyncio.wait_for(stt_task, timeout=stt_timeout) + stt_text, stt_status = await asyncio.wait_for(stt_task, timeout=stt_timeout) except asyncio.TimeoutError: logger.warning("VIDEO_STT: Whisper 等待超时(%.0fs)", stt_timeout) + stt_status = "timeout" stt_task.cancel() try: await stt_task @@ -945,6 +1265,7 @@ async def quick_recognize_video(request: Request, file: UploadFile = File(...)): pass except Exception as e: logger.warning("VIDEO_STT: 合并前异常 %s", e) + stt_status = "error" _prev_ct_len = len(str(result.get("content_text", "") or "")) _merge_stt_into_video_result(result, stt_text) @@ -961,18 +1282,52 @@ async def quick_recognize_video(request: Request, file: UploadFile = File(...)): "yes", "on", ) - if not (stt_text or "").strip() and _stt_env_on: + stt_ok = bool((stt_text or "").strip()) + if not stt_ok and _stt_env_on: logger.warning( "VIDEO_STT: 口播转写为空,正文仍主要来自视频模型/OCR;" "请看上方 VIDEO_STT 日志(ffmpeg、API、代理已改为 trust_env=False 直连)", ) _sanitize_video_meta_narrative_content(result) + body_after_stt = str(result.get("content_text", "")).strip() + + # STT 没拿到文本时,避免把“注意看”之类短钩子误当正文自动填入。 + if not stt_ok and _video_body_is_too_short_to_use(body_after_stt): + # 二次兜底:多帧抽取字幕/花字,尽量恢复视频正文 + recovered = await _recover_video_text_from_frames(client, video_bytes, container_ext) + recovered_ok = bool(recovered and not _video_body_is_too_short_to_use(recovered)) + if recovered_ok: + result["content_text"] = recovered + body_after_stt = recovered + logger.info("视频多帧字幕兜底成功 len=%s", len(recovered)) + else: + if recovered: + logger.info("视频多帧字幕兜底结果过短,丢弃 len=%s", len(recovered)) + else: + logger.info("视频多帧字幕兜底未提取到有效文本") + result["content_text"] = "" + body_after_stt = "" + t = str(result.get("title", "")).strip() + if t and _video_body_is_too_short_to_use(t): + result["title"] = "" + s = str(result.get("summary", "")).strip() + if s and _video_body_is_too_short_to_use(s): + result["summary"] = "" if _quick_payload_is_empty(result): + detail = ( + "无法从视频中识别有效文字或主题,请换片段或手动填写" + if stt_ok + else ( + "视频语音转写未获取到有效正文。" + f"(stt_status={stt_status})请检查 ffmpeg、OPENAI_WHISPER_BASE_URL、" + "OPENAI_WHISPER_API_KEY、WHISPER_MODEL,或上传含清晰字幕/口播的视频。" + ) + ) return { "success": False, - "error": "无法从视频中识别有效文字或主题,请换片段或手动填写", + "error": detail, "media_source": "video", "slot_type": "other", "extra_slots": [], diff --git a/backend/tests/test_generate_comments_api.py b/backend/tests/test_generate_comments_api.py new file mode 100644 index 0000000..0c4ed3d --- /dev/null +++ b/backend/tests/test_generate_comments_api.py @@ -0,0 +1,26 @@ +"""generate-comments API:LLM 失败时应返回 503 而非空列表。""" +import asyncio +import pytest +from unittest.mock import AsyncMock, patch + +from app.api.comments_api import generate_comments, GenerateCommentsRequest + + +def test_generate_comments_raises_on_llm_error(): + err = { + "agent_name": "BaseAgent", + "dimension": "error", + "score": 0, + "issues": ["诊断出错: API 余额不足"], + "suggestions": ["请稍后重试"], + "reasoning": "Error: 402", + } + with patch("app.api.comments_api.BaseAgent") as MockAgent: + inst = MockAgent.return_value + inst.call_llm = AsyncMock(return_value=err) + req = GenerateCommentsRequest(title="标题", content="正文", category="food", existing_count=1) + from fastapi import HTTPException + with pytest.raises(HTTPException) as exc: + asyncio.run(generate_comments(req)) + assert exc.value.status_code == 503 + assert "诊断出错" in str(exc.value.detail) diff --git a/backend/tests/test_video_text_merge.py b/backend/tests/test_video_text_merge.py index c799c2a..5e7e0fb 100644 --- a/backend/tests/test_video_text_merge.py +++ b/backend/tests/test_video_text_merge.py @@ -10,6 +10,7 @@ _strip_video_scene_caption_lines, _merge_stt_into_video_result, _video_subtitle_payload_insufficient, + _video_body_is_too_short_to_use, ) @@ -30,3 +31,14 @@ def test_merge_stt_replaces_scene_caption_only_payload(): def test_video_payload_insufficient_when_only_scene_caption(): result = {"content_text": "视频帧显示一位女士在厨房烹饪蘑菇,并叠加字幕提示不要焯水"} assert _video_subtitle_payload_insufficient(result) is True + + +def test_strip_player_overlay_noise_lines(): + text = "注意看\n00:00/00:52\n1080P" + cleaned = _strip_video_scene_caption_lines(text) + assert cleaned == "注意看" + + +def test_short_video_hook_body_not_usable(): + assert _video_body_is_too_short_to_use("注意看") is True + assert _video_body_is_too_short_to_use("第一步先热锅\n再下油") is False diff --git a/frontend/src/components/SimulatedComments.tsx b/frontend/src/components/SimulatedComments.tsx index 19f15f3..de1afd4 100644 --- a/frontend/src/components/SimulatedComments.tsx +++ b/frontend/src/components/SimulatedComments.tsx @@ -1,8 +1,10 @@ import { useState, useCallback } from "react"; +import axios from "axios"; import { Box, Typography, Button, CircularProgress } from "@mui/material"; import RefreshIcon from "@mui/icons-material/Refresh"; import type { SimulatedComment, CommentWithReplies } from "../utils/api"; import { generateComments } from "../utils/api"; +import { showToast } from "./Toast"; interface Props { comments: SimulatedComment[]; @@ -77,11 +79,40 @@ export default function SimulatedComments({ comments: initial, noteTitle = "", n }, []); const handleLoadMore = async () => { + // #region agent log + fetch("http://127.0.0.1:7868/ingest/76f492e9-821a-40ed-94f3-44f287746ef5", { method: "POST", headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7caaea" }, body: JSON.stringify({ sessionId: "7caaea", runId: "pre-fix", hypothesisId: "D", location: "SimulatedComments.tsx:handleLoadMore:entry", message: "load more clicked", data: { commentsLen: comments.length }, timestamp: Date.now() }) }).catch(() => {}); + // #endregion setLoading(true); + const req = { title: noteTitle, content: noteContent, category: noteCategory, existing_count: comments.length }; + // #region agent log + fetch("http://127.0.0.1:7868/ingest/76f492e9-821a-40ed-94f3-44f287746ef5", { method: "POST", headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7caaea" }, body: JSON.stringify({ sessionId: "7caaea", runId: "pre-fix", hypothesisId: "C", location: "SimulatedComments.tsx:handleLoadMore:req", message: "generateComments params", data: { titleLen: req.title?.length ?? 0, contentLen: req.content?.length ?? 0, category: req.category, existing_count: req.existing_count }, timestamp: Date.now() }) }).catch(() => {}); + // #endregion try { - const nc = await generateComments({ title: noteTitle, content: noteContent, category: noteCategory, existing_count: comments.length }); + const nc = await generateComments(req); + // #region agent log + fetch("http://127.0.0.1:7868/ingest/76f492e9-821a-40ed-94f3-44f287746ef5", { method: "POST", headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7caaea" }, body: JSON.stringify({ sessionId: "7caaea", runId: "pre-fix", hypothesisId: "B,E", location: "SimulatedComments.tsx:handleLoadMore:success", message: "generateComments result", data: { isArray: Array.isArray(nc), count: Array.isArray(nc) ? nc.length : null, type: typeof nc }, timestamp: Date.now() }) }).catch(() => {}); + // #endregion + if (!nc?.length) { + showToast("未能生成新评论,请稍后重试"); + return; + } setComments((prev) => [...prev, ...nc.map(toCommentState)]); - } catch { /* ignore */ } finally { setLoading(false); } + } catch (err) { + let msg = "加载评论失败,请稍后重试"; + if (axios.isAxiosError(err)) { + const d = err.response?.data; + if (d && typeof d === "object" && "detail" in d) { + const det = (d as { detail: unknown }).detail; + msg = typeof det === "string" ? det : msg; + } + } else if (err instanceof Error && err.message) { + msg = err.message; + } + showToast(msg); + // #region agent log + fetch("http://127.0.0.1:7868/ingest/76f492e9-821a-40ed-94f3-44f287746ef5", { method: "POST", headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "7caaea" }, body: JSON.stringify({ sessionId: "7caaea", runId: "post-fix", hypothesisId: "A,E", location: "SimulatedComments.tsx:handleLoadMore:catch", message: "generateComments failed", data: { err: msg }, timestamp: Date.now() }) }).catch(() => {}); + // #endregion + } finally { setLoading(false); } }; if (!comments.length) return 暂无模拟评论; diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index d0ca2c3..1017764 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -20,6 +20,15 @@ function fkey(f: File) { return `${f.name}_${f.size}_${f.lastModified}`; } +function looksLikeVideoWeakBody(text?: string) { + const s = (text || "").trim(); + if (!s) return true; + const lines = s.split("\n").map((x) => x.trim()).filter(Boolean); + if (lines.length === 0) return true; + if (lines.length === 1 && lines[0].length <= 8) return true; + return false; +} + /** 中文垂类 -> 英文 key 映射 */ const CAT_MAP: Record = { "美食": "food", "食谱": "food", "做饭": "food", "烘焙": "food", @@ -214,7 +223,10 @@ export default function Home() { for (const [, r] of successRecogEntries) { if ((r.slot_type || "").toLowerCase() === "content") { if (!bestTitle && r.title?.trim()) bestTitle = r.title.trim(); - if (r.content_text?.trim()) contentParts.push(r.content_text.trim()); + if (r.content_text?.trim()) { + const weakVideoBody = r.media_source === "video" && looksLikeVideoWeakBody(r.content_text); + if (!weakVideoBody) contentParts.push(r.content_text.trim()); + } } if (!bestCategory && r.category?.trim()) bestCategory = r.category.trim(); if (!bestSummary && r.summary?.trim()) bestSummary = r.summary.trim(); @@ -235,7 +247,10 @@ export default function Home() { } if (contentParts.length === 0) { for (const [, r] of successRecogEntries) { - if (r.content_text?.trim()) contentParts.push(r.content_text.trim()); + if (r.content_text?.trim()) { + const weakVideoBody = r.media_source === "video" && looksLikeVideoWeakBody(r.content_text); + if (!weakVideoBody) contentParts.push(r.content_text.trim()); + } } } @@ -266,7 +281,13 @@ export default function Home() { bestTitle = (firstPhrase || s).slice(0, 100); } } - if (!bestContent.trim() && bestSummary.trim()) { + // 仅视频快识时,summary 常是画面概括,不能冒充正文 + if ( + !bestContent.trim() + && bestSummary.trim() + && !videoOnlySuccess + && !looksLikeVideoWeakBody(bestSummary) + ) { bestContent = bestSummary.trim(); } @@ -328,6 +349,7 @@ export default function Home() { }, [aggregated, userEdited]); const allFailed = allRecognitionDone && successResults.length === 0 && allResults.length > 0; + const anyFailed = allRecognitionDone && allResults.some((r) => !r.success); /** 全部失败时展示后端/模型返回的首条原因,便于区分「连不上 API」与「Key/模型报错」 */ const firstRecognizeError = useMemo(() => { @@ -746,7 +768,7 @@ export default function Home() { {/* Ready state:仅在有成功识别时显示完成提示;全部失败只显示下方红字 */} - {isReady && files.length > 0 && hasRecogSuccess && ( + {isReady && files.length > 0 && hasRecogSuccess && !anyFailed && ( 分析完成,可以开始诊断 @@ -759,6 +781,11 @@ export default function Home() { : "识别失败,请检查网络或手动输入"} )} + {!allFailed && anyFailed && firstRecognizeError && ( + + {`部分素材识别失败:${firstRecognizeError}`} + + )} diff --git "a/\350\277\220\350\241\214\345\221\275\344\273\244.md" "b/\350\277\220\350\241\214\345\221\275\344\273\244.md" deleted file mode 100644 index deece05..0000000 --- "a/\350\277\220\350\241\214\345\221\275\344\273\244.md" +++ /dev/null @@ -1,10 +0,0 @@ -cp .env.example backend/.env -chmod +x start.sh # 仅需一次 -./start.sh - -cd /Users/yxz/Desktop/小红书黑客松/noterx/backend -python3 -m pip install -r requirements.txt -python3 -m uvicorn app.main:app --reload --port 8000 - -cd /Users/yxz/Desktop/小红书黑客松/noterx/frontend -npm run dev