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