diff --git a/.agents/notes/implemented/feature/2026-11-04-content-check-ai-traces.md b/.agents/notes/implemented/feature/2026-11-04-content-check-ai-traces.md new file mode 100644 index 00000000..8fbca46b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-11-04-content-check-ai-traces.md @@ -0,0 +1,75 @@ +# Agent Note: 文稿检测 AI 痕迹维度合同 + +Status: implemented — #228 一期启发式统计;无持久化、无迁移 + +## Problem + +用 AI 辅助写作的创作者想知道文稿「AI 味」重不重、具体重在哪。既有链路只有 +风险把关(违禁词/极限词/限流词),没有写作风格信号。本轮新增 ai_traces 维度: +AI 高频词/黑话命中、套话连接词命中、句式均匀度,各自分档 + 综合建议。 + +## Decision + +### 定位红线:启发式统计,不是模型检测 + +全链路(响应字段、advice、UI 文案、hover 提示)明示「启发式统计」,禁止 +「AI 率 / AI 概率 / 检测模型」类口径——词库命中与句式统计都判不了文稿来源。 +与风险把关语义分开:ai_traces 独立端点独立展示,**不并入 /sensitive 的 +total,不影响「未发现违禁词,可以发布」结论**(风险把关与写作建议不同层)。 +现链路无全局总分/评分体系,本轮不新造。 + +### 词库(backend/app/core/ai_traces/ai_lexicon.json) + +`_meta` + 两组词条:`buzzword` 154 条(预算 100~200)、`connective` 39 条 +(预算 30~50)。**人工整理**(大模型中文输出的词频观察 + 通行互联网黑话), +无单一权威来源,`_meta.source` 如实标注;若逐词要求外部背书会滑向 4/5 工作量, +首版放弃。词条校验同敏感词:不含空白/标点,非法词条显式 warning 跳过, +测试断言零跳过 + 无重复 + 预算区间。加词走普通 PR,更新改 `version_date`。 + +刻意排除:通用副词单用(显著/极大)、正常书面语常用词(但是/因此),只收 +堆积才有信号的黑话与套话,控制口语化文稿的误报。 + +### 扫描与偏移(沿用敏感词既有合同,改动两边一起改) + +分隔符 `[\s\W]` 1:1 掩码、每维度按词长降序 union 正则(同 start 保留最长)、 +start/end 是原文 codepoint 偏移、不做 NFKC。两维度命中合并后按 +(start 升序, 长度降序, buzzword 先于 connective) 贪心去重叠,区间两两不交 +——前端 `_highlight.tsx` 按 codepoint 切分对齐(emoji 不漂移)。 + +### 分档阈值(service 显式常量,改阈值必须同步测试边界断言) + +- 密度 = 命中次数 / 全文 codepoint 数 × 1000(次/千字,分母含标点空白)。 + buzzword:≥3 起 medium、≥8 high——黑话在口语化写作几乎不出现,阈值从宽。 + connective:≥2.5 起 medium、≥6 high——首先/其次在正常议论文有基础出现率, + 阈值不能压太低误伤正常行文。 +- 句式均匀度:按中文句末标点(。!?;…全半角)与换行分句,句长取 codepoint + 数,用 **CV=pstdev/mean**(无量纲,长短文可比)而非原始方差; + CV<0.20 high、0.20≤CV<0.35 medium、≥0.35 low。自然中文写作句长长短交错 + (口语样本实测 CV≈0.76),模板化行文句长趋同(密集样文实测 CV≈0.07)。 +- **insufficient 边界**:句数 <3 时 CV 无统计意义,rhythm 返回 insufficient + 且 cv=null,不编造分档。词证据与句证据独立:短文本黑话密集仍按词分档 + (word hits 参与整体);只有「句数不足且词零命中」时整体才 insufficient—— + 短到没有任何可信信号时宁可不判,也不编造「痕迹少」。 + +### 入口与前端 + +- `POST /api/v1/content-check/ai-traces`:挂在 /content-check 路由器上共享 + `get_current_user` 与限流;`max_length=20000` 与 sensitive 同口径(422)。 +- 前端 `/content-check`:开始检测时两请求 `Promise.all` 并行,结果合并成 + 单一对象进 `_scan-state` 的快照合同(泛型化后两类结果一起 current/stale)。 + 右侧新增 AI 痕迹面板(三子指标 + 分档 Badge + advice);正文高亮合并渲染: + 风险命中红/黄系不变,AI 痕迹命中**紫色系**(设计系统 --color-purple), + 同区间重叠时风险命中优先。「未发现违禁词,可以发布」只看 sensitive.total。 + +### 测试口径 + +`tests/test_ai_traces.py`:词表零跳过/预算、偏移切片还原(含 emoji)、密度与 +CV 分档边界等值行为、insufficient 边界、401/422、万字基准(均值 <500ms, +实测远低于此)。跑法同敏感词:一次性 PG 单跑或 `make test-backend`。 +零回归验证:`tests/test_sensitive_word.py` 未改动、单跑通过;前端 +`npm run test:coverage` 通过(_scan-state / _highlight 用例同步扩)。 + +## 后续(v2 再议) + +全局总分/评分体系、逐句均匀度高亮(v1 只做词级定位)、改写建议生成、 +词库入库与用户自定义、英文文稿。 diff --git a/backend/app/api/v1/content_check.py b/backend/app/api/v1/content_check.py index 8b770070..0e5c527d 100644 --- a/backend/app/api/v1/content_check.py +++ b/backend/app/api/v1/content_check.py @@ -1,8 +1,9 @@ -"""文稿检测入口——发布前违禁词 / 广告法极限词扫描。 +"""文稿检测入口——发布前违禁词 / 广告法极限词扫描,附 AI 痕迹启发式统计。 入口层只做三件事:路由、schema 校验、调 service(`sensitive_word_service.scan` -纯函数)。无 ORM、无 session。认证用 `Depends(get_current_user)` 防匿名刷大文本 -(与 skill.py 同模式);限流由 `/api/v1` 前缀的全局中间件自动覆盖,无需单列规则。 +与 `ai_trace_service.analyze` 纯函数)。无 ORM、无 session。认证用 +`Depends(get_current_user)` 防匿名刷大文本(与 skill.py 同模式);限流由 +`/api/v1` 前缀的全局中间件自动覆盖,无需单列规则。 """ from __future__ import annotations @@ -10,7 +11,13 @@ from fastapi import APIRouter, Depends from app.api.v1.auth import get_current_user -from app.schemas.content_check import SensitiveCheckRequest, SensitiveCheckResponse +from app.schemas.content_check import ( + AiTracesCheckRequest, + AiTracesReport, + SensitiveCheckRequest, + SensitiveCheckResponse, +) +from app.services.ai_trace_service import analyze as analyze_ai_traces from app.services.sensitive_word_service import scan router = APIRouter(prefix="/content-check", tags=["content-check"], dependencies=[Depends(get_current_user)]) @@ -32,3 +39,19 @@ async def check_sensitive(payload: SensitiveCheckRequest) -> SensitiveCheckRespo total=len(hits), hits=[{**hit} for hit in hits], ) + + +@router.post( + "/ai-traces", + response_model=AiTracesReport, + summary="文稿 AI 痕迹启发式统计", + description=( + "统计文稿的「AI 味」写作风格信号:AI 高频词/黑话命中(buzzword,含原文" + "字符偏移)、套话连接词命中(connective)、句式均匀度(句长变异系数分档)。" + "这是启发式统计,不是模型检测,不输出「AI 率/AI 概率」,也不影响违禁词" + "检测的把关结论。句数不足 3 句时句式维度返回 insufficient。" + "单次最多 20000 字符,超出返回 422。" + ), +) +async def check_ai_traces(payload: AiTracesCheckRequest) -> AiTracesReport: + return AiTracesReport(**analyze_ai_traces(payload.text)) diff --git a/backend/app/core/ai_traces/__init__.py b/backend/app/core/ai_traces/__init__.py new file mode 100644 index 00000000..3fc1afae --- /dev/null +++ b/backend/app/core/ai_traces/__init__.py @@ -0,0 +1,26 @@ +"""AI 痕迹维度的 dimension / level 常量(跨层共享的纯常量放 core)。 + +「AI 痕迹」是**启发式统计,不是模型检测**:词库命中与句式节奏只描述写作 +风格信号,任何输出与文案不得暗示「AI 率 / AI 概率 / 检测模型」口径。 +词表数据与来源记录在同目录 ai_lexicon.json 的 `_meta`;分档阈值与理由见 +.agents/notes/implemented/feature/2026-11-04-content-check-ai-traces.md。 +""" + +from __future__ import annotations + +# 词维度:buzzword=AI 高频词/黑话,connective=套话连接词(各有词表); +# rhythm=句式均匀度(无词表,按句长统计),单独成档不进 WORD_DIMENSIONS。 +DIMENSION_BUZZWORD = "buzzword" +DIMENSION_CONNECTIVE = "connective" +DIMENSION_RHYTHM = "rhythm" + +WORD_DIMENSIONS: tuple[str, ...] = (DIMENSION_BUZZWORD, DIMENSION_CONNECTIVE) + +# 分档:high/medium/low 描述「AI 味」浓度(high=痕迹明显),insufficient=样本 +# 不足宁可不判。与风险把关的 severity(high/medium/low)语义不同,不复用配色。 +LEVEL_HIGH = "high" +LEVEL_MEDIUM = "medium" +LEVEL_LOW = "low" +LEVEL_INSUFFICIENT = "insufficient" + +LEVELS: tuple[str, ...] = (LEVEL_HIGH, LEVEL_MEDIUM, LEVEL_LOW, LEVEL_INSUFFICIENT) diff --git a/backend/app/core/ai_traces/ai_lexicon.json b/backend/app/core/ai_traces/ai_lexicon.json new file mode 100644 index 00000000..2fc50839 --- /dev/null +++ b/backend/app/core/ai_traces/ai_lexicon.json @@ -0,0 +1,209 @@ +{ + "_meta": { + "description": "AI 痕迹维度的启发式词表:buzzword=AI 高频词/互联网黑话,connective=套话连接词。只统计写作风格信号,不代表文稿来源判定", + "source": [ + "人工整理:中文互联网公开讨论中常见的大模型生成文风高频词与套话(对大模型中文输出的词频观察,社区共识性表述,无单一权威来源)", + "通行「互联网黑话」词表的公共词条(赋能/抓手/闭环类,社区广泛流传的事实性短语)" + ], + "license": "人工整理词表,无第三方版权依赖", + "version_date": "2026-11-04", + "note": "启发式统计口径,不暗示「AI 率/AI 概率」。词条不得包含空白或标点字符(service 加载时校验,非法词条显式 warning 跳过,测试断言零跳过);buzzword 预算 100~200 条、connective 30~50 条,测试锁定。更新时改本 version_date 并复核 owning note 的阈值理由" + }, + "buzzword": [ + { "word": "赋能" }, + { "word": "抓手" }, + { "word": "闭环" }, + { "word": "底层逻辑" }, + { "word": "顶层设计" }, + { "word": "方法论" }, + { "word": "组合拳" }, + { "word": "护城河" }, + { "word": "生态圈" }, + { "word": "生态链" }, + { "word": "私域" }, + { "word": "公域" }, + { "word": "流量池" }, + { "word": "风口" }, + { "word": "赛道" }, + { "word": "蓝海" }, + { "word": "红海" }, + { "word": "内卷" }, + { "word": "破圈" }, + { "word": "出圈" }, + { "word": "裂变" }, + { "word": "引爆" }, + { "word": "链路" }, + { "word": "全链路" }, + { "word": "颗粒度" }, + { "word": "对齐" }, + { "word": "拉通" }, + { "word": "拉齐" }, + { "word": "沉淀" }, + { "word": "复盘" }, + { "word": "复用" }, + { "word": "打通" }, + { "word": "联动" }, + { "word": "协同" }, + { "word": "耦合" }, + { "word": "解耦" }, + { "word": "中台" }, + { "word": "底座" }, + { "word": "矩阵" }, + { "word": "打法" }, + { "word": "深耕" }, + { "word": "布局" }, + { "word": "落地" }, + { "word": "聚焦" }, + { "word": "发力" }, + { "word": "锚定" }, + { "word": "卡位" }, + { "word": "助力" }, + { "word": "助推" }, + { "word": "驱动" }, + { "word": "激活" }, + { "word": "释放" }, + { "word": "解锁" }, + { "word": "契机" }, + { "word": "蓄势" }, + { "word": "破局" }, + { "word": "破题" }, + { "word": "迭代" }, + { "word": "升维" }, + { "word": "蜕变" }, + { "word": "蝶变" }, + { "word": "跃迁" }, + { "word": "飞跃" }, + { "word": "质变" }, + { "word": "彰显" }, + { "word": "凸显" }, + { "word": "迸发" }, + { "word": "激荡" }, + { "word": "澎湃" }, + { "word": "涌现" }, + { "word": "汇聚" }, + { "word": "凝聚" }, + { "word": "铸就" }, + { "word": "谱写" }, + { "word": "绘就" }, + { "word": "描绘" }, + { "word": "点亮" }, + { "word": "照亮" }, + { "word": "启航" }, + { "word": "扬帆" }, + { "word": "远航" }, + { "word": "起航" }, + { "word": "腾飞" }, + { "word": "跃升" }, + { "word": "领跑" }, + { "word": "领军" }, + { "word": "答卷" }, + { "word": "画卷" }, + { "word": "篇章" }, + { "word": "新篇章" }, + { "word": "华章" }, + { "word": "华丽转身" }, + { "word": "无缝衔接" }, + { "word": "一站式" }, + { "word": "一体化" }, + { "word": "全方位" }, + { "word": "多维度" }, + { "word": "立体化" }, + { "word": "场景化" }, + { "word": "智能化" }, + { "word": "数字化" }, + { "word": "精细化" }, + { "word": "体系化" }, + { "word": "系统化" }, + { "word": "结构化" }, + { "word": "标准化" }, + { "word": "流程化" }, + { "word": "规模化" }, + { "word": "常态化" }, + { "word": "差异化" }, + { "word": "多元化" }, + { "word": "多样化" }, + { "word": "深度融合" }, + { "word": "深度解析" }, + { "word": "深度剖析" }, + { "word": "深入探讨" }, + { "word": "深入剖析" }, + { "word": "全面提升" }, + { "word": "全面升级" }, + { "word": "显著提升" }, + { "word": "显著增强" }, + { "word": "显著改善" }, + { "word": "大幅提升" }, + { "word": "极大地" }, + { "word": "有效地" }, + { "word": "深刻洞察" }, + { "word": "独到见解" }, + { "word": "前所未有" }, + { "word": "日新月异" }, + { "word": "突飞猛进" }, + { "word": "如火如荼" }, + { "word": "方兴未艾" }, + { "word": "雨后春笋" }, + { "word": "蒸蒸日上" }, + { "word": "欣欣向荣" }, + { "word": "生机勃勃" }, + { "word": "蓬勃发展" }, + { "word": "波澜壮阔" }, + { "word": "气势磅礴" }, + { "word": "丰富多彩" }, + { "word": "五彩斑斓" }, + { "word": "大放异彩" }, + { "word": "相得益彰" }, + { "word": "相辅相成" }, + { "word": "扎实推进" }, + { "word": "稳步推进" }, + { "word": "持续推进" }, + { "word": "纵深推进" }, + { "word": "提质增效" }, + { "word": "降本增效" }, + { "word": "凝心聚力" }, + { "word": "走深走实" }, + { "word": "行稳致远" }, + { "word": "砥砺前行" } + ], + "connective": [ + { "word": "首先" }, + { "word": "其次" }, + { "word": "再次" }, + { "word": "最后" }, + { "word": "此外" }, + { "word": "另外" }, + { "word": "与此同时" }, + { "word": "综上所述" }, + { "word": "综上" }, + { "word": "总而言之" }, + { "word": "总的来说" }, + { "word": "总之" }, + { "word": "由此可见" }, + { "word": "也就是说" }, + { "word": "换句话说" }, + { "word": "换言之" }, + { "word": "简而言之" }, + { "word": "简言之" }, + { "word": "具体而言" }, + { "word": "具体来说" }, + { "word": "总体而言" }, + { "word": "整体而言" }, + { "word": "综合来看" }, + { "word": "一方面" }, + { "word": "另一方面" }, + { "word": "不仅如此" }, + { "word": "除此之外" }, + { "word": "值得注意的是" }, + { "word": "需要注意的是" }, + { "word": "值得一提的是" }, + { "word": "更重要的是" }, + { "word": "毫无疑问" }, + { "word": "毋庸置疑" }, + { "word": "不可否认" }, + { "word": "众所周知" }, + { "word": "显然" }, + { "word": "无疑" }, + { "word": "事实上" }, + { "word": "进一步" } + ] +} diff --git a/backend/app/schemas/content_check.py b/backend/app/schemas/content_check.py index 176d7ded..5937f278 100644 --- a/backend/app/schemas/content_check.py +++ b/backend/app/schemas/content_check.py @@ -1,4 +1,4 @@ -"""文稿检测(违禁词 / 广告法极限词)请求与响应模型。 +"""文稿检测(违禁词 / 广告法极限词 / AI 痕迹统计)请求与响应模型。 纯 Pydantic 校验与序列化,无 ORM 导入、无 DB 访问。 `max_length=20000` 由 FastAPI 自动返回 422,service 与词库均不感知长度上限。 @@ -10,10 +10,13 @@ from pydantic import BaseModel, Field +from app.core.ai_traces import LEVELS, WORD_DIMENSIONS from app.core.sensitive_words import CATEGORIES, SEVERITIES Severity = Literal["high", "medium", "low"] +AiTraceLevel = Literal["low", "medium", "high", "insufficient"] + class SensitiveCheckRequest(BaseModel): """POST /content-check/sensitive 请求体。""" @@ -44,3 +47,56 @@ class SensitiveCheckResponse(BaseModel): total: int = Field(..., ge=0, description="命中总数") hits: list[SensitiveHitResponse] = Field(default_factory=list, description="命中清单(start 升序)") + + +class AiTracesCheckRequest(BaseModel): + """POST /content-check/ai-traces 请求体(与 sensitive 同长度口径)。""" + + text: str = Field( + ..., + min_length=1, + max_length=20000, + description="待统计文稿原文(1~20000 字符,超限返回 422)", + ) + + +class AiTraceHitResponse(BaseModel): + """单条词命中:word/dimension + 原文字符偏移 start/end(codepoint 坐标)。""" + + word: str = Field(..., description="命中的词表词条") + dimension: Literal["buzzword", "connective"] = Field(..., description=f"词维度,取值 {WORD_DIMENSIONS}") + start: int = Field(..., ge=0, description="命中原文起始字符偏移(含)") + end: int = Field(..., gt=0, description="命中原文结束字符偏移(不含)") + + +class AiTraceDimensionStat(BaseModel): + """词维度统计:命中数、密度(次/千字)与分档。""" + + dimension: Literal["buzzword", "connective"] + hits: int = Field(..., ge=0, description="命中次数") + density_per_kilochar: float = Field(..., ge=0, description="密度(次/千字)") + level: AiTraceLevel = Field(..., description=f"分档,取值 {LEVELS}") + + +class AiTraceRhythmStat(BaseModel): + """句式均匀度统计:CV=std/mean;句数 <3 时 level=insufficient 且 cv=null。""" + + level: AiTraceLevel + sentence_count: int = Field(..., ge=0, description="有效句数") + cv: float | None = Field(None, description="句长变异系数;样本不足时为 null") + + +class AiTracesReport(BaseModel): + """POST /content-check/ai-traces 响应。 + + 启发式统计(非模型检测):不输出「AI 率/AI 概率」,不影响风险把关结论, + 只给写作建议,前端独立展示。 + """ + + level: AiTraceLevel = Field(..., description=f"总分档,取值 {LEVELS}") + character_count: int = Field(..., ge=0, description="全文 codepoint 数") + buzzword: AiTraceDimensionStat = Field(..., description="AI 高频词统计") + connective: AiTraceDimensionStat = Field(..., description="套话连接词统计") + rhythm: AiTraceRhythmStat = Field(..., description="句式均匀度统计") + hits: list[AiTraceHitResponse] = Field(default_factory=list, description="词命中清单(start 升序、区间不重叠)") + advice: str = Field(..., description="综合建议(明示启发式统计口径)") diff --git a/backend/app/services/ai_trace_service.py b/backend/app/services/ai_trace_service.py new file mode 100644 index 00000000..a8faaa29 --- /dev/null +++ b/backend/app/services/ai_trace_service.py @@ -0,0 +1,269 @@ +"""文稿「AI 痕迹」启发式统计(AI 高频词 / 套话连接词 / 句式均匀度)。 + +定位与口径(owning note: 2026-11-04-content-check-ai-traces.md,改动前先读): + +- **这是启发式统计,不是模型检测**:词库命中与句式节奏只描述写作风格信号, + 输出与文案不得暗示「AI 率 / AI 概率 / 检测模型」。与敏感词链路的风险把关 + 语义不同——ai_traces 只给写作建议,不影响「未发现违禁词,可以发布」结论。 +- 词扫描沿用敏感词链路的既有合同:全角/半角标点与空白视为分隔符 + (`[\\s\\W]` 1:1 替换为 `\\x00`,偏移不变)、每维度一个按词长降序的 union + 正则(同 start 保留最长)、start/end 是原文 codepoint 偏移、不做 NFKC。 +- 两维度命中合并后按 (start 升序, 长度降序, buzzword 先于 connective) 贪心 + 去重叠,结果区间两两不交——前端高亮依赖该合同。 +- 句式均匀度:按中文句末标点(。!?;…及半角对应)与换行分句,句长取 + codepoint 数,用**变异系数 CV=std/mean**(无量纲,长文短文可比)而非原始 + 方差;句数 < 3 时 CV 无统计意义,返回 insufficient,不编造分档。 +""" + +from __future__ import annotations + +import functools +import json +import logging +import re +import statistics +from pathlib import Path +from typing import TypedDict + +from app.core.ai_traces import ( + DIMENSION_BUZZWORD, + DIMENSION_CONNECTIVE, + LEVEL_HIGH, + LEVEL_INSUFFICIENT, + LEVEL_LOW, + LEVEL_MEDIUM, + WORD_DIMENSIONS, +) + +logger = logging.getLogger(__name__) + +# ── 分档阈值(显式常量;理由见 owning note,改阈值必须同步改测试边界断言)───── + +# 密度分档(次/千字,分母是全文 codepoint 数、含标点空白)。buzzword +# (赋能/抓手类黑话)在口语化写作里几乎不出现,堆积即是强信号,阈值从宽: +# ≥3/千字起值得注意,≥8/千字判密集。connective(首先/其次类套话连接词)在 +# 正常议论文里也有基础出现率,阈值压太低会误伤正常行文,故只略低半档: +# ≥2.5/千字起注意,≥6/千字判密集。 +BUZZWORD_DENSITY_MEDIUM = 3.0 +BUZZWORD_DENSITY_HIGH = 8.0 +CONNECTIVE_DENSITY_MEDIUM = 2.5 +CONNECTIVE_DENSITY_HIGH = 6.0 + +# 句式均匀度分档(CV=std/mean,pstdev)。CV 越小句长越均匀;自然中文写作 +# 句长长短交错,CV 普遍 ≥0.4;模板化行文句长趋同,CV 明显压低。取 +# CV<0.20 判 high(高度均匀),0.20≤CV<0.35 判 medium,CV≥0.35 判 low。 +RHYTHM_CV_HIGH = 0.20 +RHYTHM_CV_LOW = 0.35 + +# 句数下限:n<3 时样本太小,任何 CV 都不可信,rhythm 与整体都只给 insufficient +MIN_SENTENCES = 3 + +# 与敏感词链路同口径:空白 + 所有标点/符号(含全角)是分隔符,1:1 替换、偏移不变 +_SEPARATOR_RE = re.compile(r"[\s\W]") +_PLACEHOLDER = "\x00" + +# 分句:中文句末标点(全半角)与换行。分号在中文承担句间停顿,也作句界。 +_SENTENCE_SPLIT_RE = re.compile(r"[。!?!?;;…\n]+") + +# 跨维度决胜权重:完全同区间时黑话比连接词信号更强更具体,buzzword 优先 +_DIMENSION_RANK: dict[str, int] = {DIMENSION_BUZZWORD: 0, DIMENSION_CONNECTIVE: 1} + +# 分档排序权重(insufficient 不参与比较,只作整体降级信号) +_LEVEL_RANK: dict[str, int] = {LEVEL_LOW: 0, LEVEL_MEDIUM: 1, LEVEL_HIGH: 2} + + +class AiTraceWordHit(TypedDict): + """一次词命中:词条、词维度与原文 codepoint 偏移。""" + + word: str + dimension: str + start: int + end: int + + +class AiTraceDimensionStat(TypedDict): + """词维度统计:命中数、密度(次/千字)与分档。""" + + dimension: str + hits: int + density_per_kilochar: float + level: str + + +class AiTraceRhythmStat(TypedDict): + """句式均匀度统计:CV;句数不足时 level=insufficient 且 cv=null。""" + + level: str + sentence_count: int + cv: float | None + + +class AiTraceReport(TypedDict): + """整体报告:三子指标 + 总分档 + 综合建议(启发式统计口径)。""" + + level: str + character_count: int + buzzword: AiTraceDimensionStat + connective: AiTraceDimensionStat + rhythm: AiTraceRhythmStat + hits: list[AiTraceWordHit] + advice: str + + +_ADVICE: dict[str, str] = { + LEVEL_LOW: ("未观察到密集的 AI 高频词与套话连接词,句式节奏自然,保持即可。" "启发式统计,仅供参考。"), + LEVEL_MEDIUM: ( + "存在一定密度的 AI 高频词或套话连接词,可对照命中清单换成具体动作、" "数字或事例。启发式统计,仅供参考。" + ), + LEVEL_HIGH: ( + "AI 高频词/套话连接词密集,或句式过于均匀,建议逐条改写命中词、" "打散均匀的句长节奏。启发式统计,仅供参考。" + ), + LEVEL_INSUFFICIENT: ("文本太短(有效句数不足 3),样本不足以统计,补充更多正文后再看。" "启发式统计,仅供参考。"), +} + + +@functools.cache +def load_lexicon(dimension: str) -> tuple[str, ...]: + """加载某词维度的词表(进程内缓存)。 + + 词条校验同敏感词:不含空白/标点、非空,非法词条显式 warning 跳过—— + 静态数据出错不能安静失败,也不能拖垮接口;测试断言内置词表零跳过。 + """ + if dimension not in WORD_DIMENSIONS: + raise ValueError(f"未知 AI 痕迹词维度: {dimension}") + path = Path(__file__).resolve().parent.parent / "core" / "ai_traces" / "ai_lexicon.json" + data = json.loads(path.read_text(encoding="utf-8")) + validated: list[str] = [] + for entry in data.get(dimension, []): + word = str(entry.get("word", "")) + if not word or _SEPARATOR_RE.search(word): + logger.warning("AI 痕迹词表 %s 跳过含分隔符/空词条: %r", dimension, entry) + continue + validated.append(word) + if not validated: + raise ValueError(f"AI 痕迹词表 {dimension} 为空或全部词条非法") + return tuple(validated) + + +@functools.cache +def _compiled_patterns() -> dict[str, re.Pattern[str]]: + """每词维度一个按词长降序的 union 正则(同 start 保留最长,同敏感词合同)。""" + patterns: dict[str, re.Pattern[str]] = {} + for dimension in WORD_DIMENSIONS: + words = sorted(load_lexicon(dimension), key=len, reverse=True) + patterns[dimension] = re.compile("|".join(re.escape(w) for w in words)) + return patterns + + +def _band_from_density(density: float, medium_at: float, high_at: float) -> str: + """密度越高 AI 痕迹越重:≥high_at → high;≥medium_at → medium;否则 low。""" + if density >= high_at: + return LEVEL_HIGH + if density >= medium_at: + return LEVEL_MEDIUM + return LEVEL_LOW + + +def _band_from_cv(cv: float) -> str: + """CV 越小句长越均匀(AI 痕迹越重): list[str]: + """按中文句末标点与换行分句;去掉首尾空白,丢弃纯空白句。""" + return [s.strip() for s in _SENTENCE_SPLIT_RE.split(text) if s.strip()] + + +def scan_words(text: str) -> list[AiTraceWordHit]: + """扫描 buzzword/connective 命中(start 升序、区间两两不交)。 + + 跨维度同 start 取更长词;完全同区间时 buzzword 先于 connective。 + 纯函数:同一输入恒得同一输出;空串返回空列表。 + """ + if not text: + return [] + + # 分隔符 1:1 替换为占位符:不可能跨标点命中,偏移与原文严格一致 + masked = _SEPARATOR_RE.sub(_PLACEHOLDER, text) + + raw: list[AiTraceWordHit] = [] + for dimension, pattern in _compiled_patterns().items(): + for m in pattern.finditer(masked): + raw.append(AiTraceWordHit(word=m.group(), dimension=dimension, start=m.start(), end=m.end())) + + raw.sort(key=lambda h: (h["start"], -(h["end"] - h["start"]), _DIMENSION_RANK[h["dimension"]])) + + hits: list[AiTraceWordHit] = [] + last_end = 0 + for hit in raw: + if hit["start"] >= last_end: + hits.append(hit) + last_end = hit["end"] + return hits + + +def analyze_rhythm(sentences: list[str]) -> AiTraceRhythmStat: + """句式均匀度:CV=pstdev/mean;句数 0 恒成立;防御异常输入按 CV=0(最均匀)处理 + cv = statistics.pstdev(lengths) / mean if mean > 0 else 0.0 + return AiTraceRhythmStat( + level=_band_from_cv(cv), + sentence_count=len(sentences), + cv=round(cv, 4), + ) + + +def analyze(text: str) -> AiTraceReport: + """对全文做启发式统计:三子指标分档 + 总分档 + 综合建议(纯函数)。 + + 总分档取可用子指标的最重档;rhythm=insufficient 只是样本不足、不参与 + 抬档。仅当 rhythm 样本不足**且**词维度零命中时,整体才 insufficient—— + 短到没有任何可信信号时宁可不判,也不编造「痕迹少」。词命中密集的短文本 + 仍按词证据正常分档。 + """ + character_count = len(text) + word_hits = scan_words(text) + + def dimension_stat(dimension: str) -> AiTraceDimensionStat: + hits = sum(1 for h in word_hits if h["dimension"] == dimension) + density = round(hits / character_count * 1000, 2) if character_count else 0.0 + if dimension == DIMENSION_BUZZWORD: + level = _band_from_density(density, BUZZWORD_DENSITY_MEDIUM, BUZZWORD_DENSITY_HIGH) + else: + level = _band_from_density(density, CONNECTIVE_DENSITY_MEDIUM, CONNECTIVE_DENSITY_HIGH) + return AiTraceDimensionStat( + dimension=dimension, + hits=hits, + density_per_kilochar=density, + level=level, + ) + + buzzword = dimension_stat(DIMENSION_BUZZWORD) + connective = dimension_stat(DIMENSION_CONNECTIVE) + rhythm = analyze_rhythm(split_sentences(text)) + + if rhythm["level"] == LEVEL_INSUFFICIENT and not word_hits: + level = LEVEL_INSUFFICIENT + else: + candidates = [buzzword["level"], connective["level"]] + if rhythm["level"] != LEVEL_INSUFFICIENT: + candidates.append(rhythm["level"]) + level = max(candidates, key=lambda item: _LEVEL_RANK[item]) + + return AiTraceReport( + level=level, + character_count=character_count, + buzzword=buzzword, + connective=connective, + rhythm=rhythm, + hits=word_hits, + advice=_ADVICE[level], + ) diff --git a/backend/tests/test_ai_traces.py b/backend/tests/test_ai_traces.py new file mode 100644 index 00000000..fc9870c1 --- /dev/null +++ b/backend/tests/test_ai_traces.py @@ -0,0 +1,253 @@ +"""AI 痕迹启发式统计(service 纯函数)与 POST /content-check/ai-traces(入口层)测试。 + +覆盖(对应任务书验收): +1. 内置词表零跳过且在预算区间:buzzword 100~200 条、connective 30~50 条, + 词条不含分隔符、无重复(「禁止安静失败」的机器化); +2. 密集 AI 套话样文命中并返回 codepoint 偏移,source[start:end]==word,含 emoji; +3. 口语化短句样文分档 low;三子指标分档边界(密度与 CV 阈值等值行为); +4. 句数 <3 返回 insufficient,不编造分档(词证据充分时除外); +5. 未认证 401、空文本 422、超长 422(与 sensitive 同口径); +6. 一万字统计多次取均值 < 500ms。 + +测试命令:一次性 PG 单跑(conftest 的 autouse fixture 需要 PG),如 +bash backend/scripts/test_full_local.sh 迭代期可自起一次性 PG 后 +DATABASE_URL=postgresql+asyncpg://... python -m pytest tests/test_ai_traces.py +""" + +from __future__ import annotations + +import time +from types import SimpleNamespace + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from app.api.v1.auth import get_current_user +from app.api.v1.content_check import router as content_check_router +from app.core.ai_traces import ( + DIMENSION_BUZZWORD, + DIMENSION_CONNECTIVE, + LEVEL_HIGH, + LEVEL_INSUFFICIENT, + LEVEL_LOW, + LEVEL_MEDIUM, + LEVELS, +) +from app.services import ai_trace_service as svc +from app.services.ai_trace_service import analyze, load_lexicon + +# 密集 AI 套话样文:黑话 + 首先/其次/最后 模板结构 + 句长均匀 +DENSE_TEXT = ( + "在流量红利见顶的今天,品牌必须找到新的增长抓手。" + "我们以用户为中心重构底层逻辑,通过私域运营形成增长闭环。" + "这套组合拳能够有效赋能创作者,显著提升转化效率。" + "首先对齐目标,其次沉淀方法论,最后形成可复用的打法。" +) + +# 口语化样文:零黑话零套话连接词,句长长短交错(25/1/45) +COLLOQUIAL_TEXT = ( + "今天下楼吃了一碗牛肉面,老板还是给我多加了一勺辣油。" + "爽。" + "吃完顺着河边遛了半小时弯儿,风一吹整个人都清醒了,回家倒头睡了个午觉,猫窝在我脚边也睡着了。" +) + +# ── Service:词表与纯函数 ──────────────────────────────────────────────── + + +def test_builtin_lexicons_all_valid_and_within_size_budget(): + """内置词表零跳过:词条非空、不含分隔符、无重复;数量在预算区间。""" + buzz = load_lexicon(DIMENSION_BUZZWORD) + conn = load_lexicon(DIMENSION_CONNECTIVE) + assert 100 <= len(buzz) <= 200, f"buzzword 词量超出预算: {len(buzz)}" + assert 30 <= len(conn) <= 50, f"connective 词量超出预算: {len(conn)}" + for dimension, words in ((DIMENSION_BUZZWORD, buzz), (DIMENSION_CONNECTIVE, conn)): + for word in words: + assert word, f"{dimension} 存在空词条" + assert not svc._SEPARATOR_RE.search(word), f"{dimension} 词条含空白/标点,会被加载校验跳过: {word!r}" + assert len(set(words)) == len(words), f"{dimension} 存在重复词条" + + +def test_dense_buzzword_text_hits_with_sliceable_offsets(): + """密集 AI 套话样文:命中逐词带 codepoint 偏移,可用原文切片还原。""" + report = analyze(DENSE_TEXT) + words = [h["word"] for h in report["hits"]] + for expected in ("抓手", "底层逻辑", "闭环", "组合拳", "赋能", "显著提升", "对齐", "沉淀", "方法论", "打法"): + assert expected in words, f"样文未命中 {expected}" + for expected in ("首先", "其次", "最后"): + assert expected in words + for hit in report["hits"]: + assert DENSE_TEXT[hit["start"] : hit["end"]] == hit["word"] + assert hit["dimension"] in (DIMENSION_BUZZWORD, DIMENSION_CONNECTIVE) + # 区间两两不交且 start 升序(前端高亮合同) + for prev, cur in zip(report["hits"], report["hits"][1:], strict=False): + assert prev["start"] < cur["start"] + assert prev["end"] <= cur["start"] + + +def test_offsets_are_original_codepoint_coordinates_with_emoji(): + """emoji 场景偏移不漂移:😀 各占 1 个 codepoint。""" + text = "😀😀首先,我们要赋能创作者。" + report = analyze(text) + hits = {(h["word"], h["start"], h["end"]) for h in report["hits"]} + assert ("首先", 2, 4) in hits + assert ("赋能", 8, 10) in hits + for hit in report["hits"]: + assert text[hit["start"] : hit["end"]] == hit["word"] + + +def test_no_word_match_across_punctuation_or_whitespace(): + """标点与空白是分隔符:不得跨标点命中(同敏感词合同)。""" + assert [h["word"] for h in svc.scan_words("底。层逻辑")] == [] + assert [h["word"] for h in svc.scan_words("赋 能")] == [] + + +def test_colloquial_text_is_low(): + """口语化短句样文:三子指标与总分档均为 low。""" + report = analyze(COLLOQUIAL_TEXT) + assert report["level"] == LEVEL_LOW + assert report["buzzword"]["level"] == LEVEL_LOW + assert report["connective"]["level"] == LEVEL_LOW + assert report["rhythm"]["level"] == LEVEL_LOW + assert report["hits"] == [] + + +def test_density_band_boundaries(): + """密度分档边界:等值进高档判定(≥),阈值下方逐级回落。""" + band = svc._band_from_density + for medium_at, high_at in ( + (svc.BUZZWORD_DENSITY_MEDIUM, svc.BUZZWORD_DENSITY_HIGH), + (svc.CONNECTIVE_DENSITY_MEDIUM, svc.CONNECTIVE_DENSITY_HIGH), + ): + assert band(high_at, medium_at, high_at) == LEVEL_HIGH + assert band(high_at - 0.001, medium_at, high_at) == LEVEL_MEDIUM + assert band(medium_at, medium_at, high_at) == LEVEL_MEDIUM + assert band(medium_at - 0.001, medium_at, high_at) == LEVEL_LOW + assert band(0.0, medium_at, high_at) == LEVEL_LOW + + +def test_rhythm_band_boundaries(): + """句式均匀度分档边界:CV 等值行为与极端样本。""" + assert svc._band_from_cv(svc.RHYTHM_CV_HIGH) == LEVEL_MEDIUM # CV=0.20 等值 → medium + assert svc._band_from_cv(svc.RHYTHM_CV_HIGH - 1e-9) == LEVEL_HIGH + assert svc._band_from_cv(svc.RHYTHM_CV_LOW) == LEVEL_LOW # CV=0.35 等值 → low + assert svc._band_from_cv(svc.RHYTHM_CV_LOW - 1e-9) == LEVEL_MEDIUM + # 句长完全一致 → CV=0 → high;长短交错 → low + assert svc.analyze_rhythm(["十二三四五六七", "十二三四五六七", "十二三四五六七"])["level"] == LEVEL_HIGH + assert ( + svc.analyze_rhythm(["一二三四五六七八九十一二三四五六七", "一", "一二三四五六七八九十一二三四五六七八九"])[ + "level" + ] + == LEVEL_LOW + ) + # 端到端:密集样文句长均匀(CV<0.20)→ rhythm high + assert analyze(DENSE_TEXT)["rhythm"]["level"] == LEVEL_HIGH + + +def test_insufficient_when_fewer_than_three_sentences(): + """句数 <3:rhythm 返回 insufficient 且 cv=null,不编造分档。""" + report = analyze("赋能抓手闭环。") # 1 句,但词证据密集 + assert report["rhythm"]["level"] == LEVEL_INSUFFICIENT + assert report["rhythm"]["cv"] is None + assert report["rhythm"]["sentence_count"] == 1 + # 词命中仍参与分档:短文本密集黑话照样判 high,不因句少整体弃判 + assert report["buzzword"]["hits"] == 3 + assert report["level"] == LEVEL_HIGH + + # 句少且零命中:无任何可信信号,整体 insufficient 而非编造「痕迹少」 + empty_report = analyze("好的。") + assert empty_report["level"] == LEVEL_INSUFFICIENT + assert "不足" in empty_report["advice"] + + # 恰好 3 句可判 + assert svc.analyze_rhythm(["一句", "一句", "一句"])["level"] != LEVEL_INSUFFICIENT + + +def test_advice_mentions_heuristic_caliber(): + """综合建议必须明示启发式统计口径,且不出现模型检测类措辞。""" + for text in (DENSE_TEXT, COLLOQUIAL_TEXT, "好的。", "首先赋能,其次沉淀,最后闭环,形成打法。"): + advice = analyze(text)["advice"] + assert "启发式统计" in advice + for banned in ("AI 率", "AI率", "概率", "检测模型"): + assert banned not in advice + + +def test_benchmark_10k_chars_under_500ms(): + """一万字统计多次取均值 < 500ms(词扫描 + 分句 + CV,全链路预算)。""" + paragraph = DENSE_TEXT + COLLOQUIAL_TEXT + text = (paragraph * 60)[:10000] + assert len(text) == 10000 + analyze(text) # 预热(含词表加载与正则编译) + durations = [] + for _ in range(5): + t0 = time.perf_counter() + analyze(text) + durations.append(time.perf_counter() - t0) + avg = sum(durations) / len(durations) + assert avg < 0.5, f"1 万字平均统计耗时 {avg * 1000:.1f}ms 超出 500ms 预算" + + +# ── API:POST /content-check/ai-traces ───────────────────────────────── + + +@pytest_asyncio.fixture +async def app(): + app = FastAPI() + app.include_router(content_check_router) + return app + + +@pytest_asyncio.fixture +async def anon_client(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client + + +@pytest_asyncio.fixture +async def authed_client(app): + app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(id=1, email="writer@example.com") + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client + + +@pytest.mark.asyncio +async def test_ai_traces_endpoint_rejects_anonymous(anon_client): + """未认证请求 401(与 sensitive 同口径,防匿名刷大文本)。""" + resp = await anon_client.post("/content-check/ai-traces", json={"text": "正常文稿"}) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_ai_traces_endpoint_returns_report_with_offsets(authed_client): + resp = await authed_client.post("/content-check/ai-traces", json={"text": DENSE_TEXT}) + assert resp.status_code == 200 + body = resp.json() + assert body["level"] in LEVELS + assert body["character_count"] == len(DENSE_TEXT) + for key in ("buzzword", "connective"): + stat = body[key] + assert stat["dimension"] == key + assert stat["hits"] > 0 and stat["density_per_kilochar"] > 0 + assert stat["level"] in LEVELS + assert body["rhythm"]["sentence_count"] == 4 + assert body["rhythm"]["cv"] is not None + assert "启发式统计" in body["advice"] + assert body["buzzword"]["hits"] + body["connective"]["hits"] == len(body["hits"]) + for hit in body["hits"]: + assert DENSE_TEXT[hit["start"] : hit["end"]] == hit["word"] + + +@pytest.mark.asyncio +async def test_ai_traces_endpoint_rejects_empty_and_overlong_text(authed_client): + empty = await authed_client.post("/content-check/ai-traces", json={"text": ""}) + assert empty.status_code == 422 + over = await authed_client.post("/content-check/ai-traces", json={"text": "好" * 20001}) + assert over.status_code == 422 + # 边界内不因长度拒绝(无标点长文本 rhythm 判 insufficient,属合法响应) + ok = await authed_client.post("/content-check/ai-traces", json={"text": "安" * 20000}) + assert ok.status_code == 200 + assert ok.json()["rhythm"]["level"] == LEVEL_INSUFFICIENT + assert ok.json()["level"] == LEVEL_INSUFFICIENT diff --git a/frontend/src/app/content-check/_highlight.test.tsx b/frontend/src/app/content-check/_highlight.test.tsx index 42484534..0e0f95a7 100644 --- a/frontend/src/app/content-check/_highlight.test.tsx +++ b/frontend/src/app/content-check/_highlight.test.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; -import { HighlightText, buildHighlightSegments, buildHoverTitle } from './_highlight'; -import type { SensitiveHit } from '@/types/content-check'; +import { HighlightText, buildAiTraceHoverTitle, buildHighlightSegments, buildHoverTitle } from './_highlight'; +import type { AiTraceWordHit, SensitiveHit } from '@/types/content-check'; const hit = (over: Partial): SensitiveHit => ({ word: '最好', @@ -14,6 +14,14 @@ const hit = (over: Partial): SensitiveHit => ({ ...over, }); +const aiHit = (over: Partial = {}): AiTraceWordHit => ({ + word: '赋能', + dimension: 'buzzword', + start: 0, + end: 2, + ...over, +}); + describe('buildHighlightSegments', () => { it('splits text into plain/hit/plain segments in order', () => { const segments = buildHighlightSegments('这是最好的时代', [hit({ start: 2, end: 4 })]); @@ -95,6 +103,67 @@ describe('buildHoverTitle', () => { }); }); +describe('AI 痕迹命中高亮', () => { + it('renders ai trace words as purple distinct from risk red/yellow', () => { + const html = renderToStaticMarkup( + React.createElement(HighlightText, { + text: '这套打法能够赋能创作者', + hits: [aiHit({ word: '赋能', start: 6, end: 8 })], + }), + ); + expect(html).toContain('赋能'); + expect(html).toContain('data-ai-trace-word="赋能"'); + expect(html).toContain('data-ai-trace-dimension="buzzword"'); + expect(html).toContain('bg-purple-light'); // AI 痕迹独立配色,非风险红/黄 + expect(html).not.toContain('bg-red-100'); + expect(decode(html)).toContain('启发式统计'); + }); + + it('renders sensitive and ai trace hits together, each with its own color', () => { + const html = renderToStaticMarkup( + React.createElement(HighlightText, { + text: '最好的增长抓手是赋能用户', + hits: [hit({ word: '最好', start: 0, end: 2 }), aiHit({ word: '抓手', start: 5, end: 7 }), aiHit({ word: '赋能', start: 8, end: 10 })], + }), + ); + expect(html).toContain('bg-red-100'); // 风险命中红色 + expect(html).toContain('bg-purple-light'); // AI 痕迹命中紫色 + expect(html).toContain('>最好'); + expect(html).toContain('>抓手'); + expect(html).toContain('>赋能'); + }); + + it('prefers the sensitive hit on an identical span (risk verdict outranks writing advice)', () => { + const segments = buildHighlightSegments('最好的选择', [ + aiHit({ word: '最好', start: 0, end: 2 }), + hit({ word: '最好', start: 0, end: 2 }), + ]); + expect(segments).toEqual([ + { text: '最好', hit: hit({ start: 0, end: 2 }) }, + { text: '的选择' }, + ]); + }); + + it('keeps ai trace offsets aligned on codepoints (emoji before the hit)', () => { + const segments = buildHighlightSegments('😀这个闭环跑通了', [aiHit({ word: '闭环', start: 3, end: 5 })]); + expect(segments.map((s) => s.text)).toEqual(['😀这个', '闭环', '跑通了']); + expect(segments[1].hit?.dimension).toBe('buzzword'); + }); +}); + +describe('buildAiTraceHoverTitle', () => { + it('carries word, dimension label and the heuristic caliber', () => { + expect(buildAiTraceHoverTitle(aiHit({ word: '抓手', dimension: 'buzzword', start: 0, end: 2 }))).toContain( + '「抓手」AI 高频词 · 启发式统计', + ); + expect(buildAiTraceHoverTitle(aiHit({ word: '首先', dimension: 'connective', start: 0, end: 2 }))).toContain( + '「首先」套话连接词 · 启发式统计', + ); + expect(buildAiTraceHoverTitle(aiHit())).toContain('建议:换成具体动作、数字或事例'); + }); +}); + /** renderToStaticMarkup 会把 title 里的换行转义成 之类的实体,比对前还原。 */ function decode(html: string): string { return html diff --git a/frontend/src/app/content-check/_highlight.tsx b/frontend/src/app/content-check/_highlight.tsx index 8cf54830..efbcf916 100644 --- a/frontend/src/app/content-check/_highlight.tsx +++ b/frontend/src/app/content-check/_highlight.tsx @@ -3,7 +3,7 @@ /** * 文稿检测的高亮渲染(从 page.tsx 拆出便于测试)。 * - * 偏移口径(与后端 sensitive_word_service 一致,改动两边一起改): + * 偏移口径(与后端 sensitive_word_service / ai_trace_service 一致,改动两边一起改): * 后端 start/end 是 Unicode codepoint 偏移(Python len 口径),而 JS 字符串 * 下标是 UTF-16 code unit——emoji 等增补平面字符两者会错位。因此这里必须 * 用 Array.from 按 codepoint 切分后再按偏移取段,不能直接 text.slice。 @@ -12,23 +12,30 @@ import React from 'react'; import { cx } from '@/components/ui'; import { + AI_TRACE_WORD_DIMENSION_LABELS, SENSITIVE_CATEGORY_META, SENSITIVE_SEVERITY_LABELS, + type AiTraceWordHit, type SensitiveHit, type SensitiveSeverity, } from '@/types/content-check'; -export interface HighlightSegment { +/** 高亮命中:风险命中(敏感词)或写作建议命中(AI 痕迹词),共用 codepoint 偏移合同。 */ +export type HighlightHit = SensitiveHit | AiTraceWordHit; + +export interface HighlightSegment { text: string; /** 命中段携带完整 hit;纯文本段为 undefined。 */ - hit?: SensitiveHit; + hit?: T; } /** 把原文切成纯文本/命中交替的段(后端保证命中区间有序且不重叠,此处仍防御排序与夹取)。 */ -export function buildHighlightSegments(text: string, hits: SensitiveHit[]): HighlightSegment[] { +export function buildHighlightSegments(text: string, hits: T[]): HighlightSegment[] { const chars = Array.from(text); - const ordered = [...hits].sort((a, b) => a.start - b.start || b.end - a.end); - const segments: HighlightSegment[] = []; + // 同 start 长词优先;完全同区间时风险命中(sensitive)先于写作建议(ai_traces) + const kindRank = (h: HighlightHit): number => ('severity' in h ? 0 : 1); + const ordered = [...hits].sort((a, b) => a.start - b.start || b.end - a.end || kindRank(a) - kindRank(b)); + const segments: HighlightSegment[] = []; let cursor = 0; for (const hit of ordered) { const start = Math.max(hit.start, cursor); @@ -48,7 +55,10 @@ const SEVERITY_MARK_CLS: Record = { low: 'bg-yellow-50 text-yellow-700', }; -/** hover 提示:命中词 + 分类/严重度 + 替换建议(原生 title,无 JS 态也可达)。 */ +/** AI 痕迹命中统一紫色系(设计系统 --color-purple),与风险红/黄明确区分。 */ +const AI_TRACE_MARK_CLS = 'bg-purple-light text-purple'; + +/** 风险命中 hover 提示:命中词 + 分类/严重度 + 替换建议(原生 title,无 JS 态也可达)。 */ export function buildHoverTitle(hit: SensitiveHit): string { const meta = SENSITIVE_CATEGORY_META[hit.category]; const head = `「${hit.word}」${meta?.label ?? hit.category} · ${SENSITIVE_SEVERITY_LABELS[hit.severity]}`; @@ -56,25 +66,44 @@ export function buildHoverTitle(hit: SensitiveHit): string { return `${head}\n${advice}`; } -export function HighlightText({ text, hits }: { text: string; hits: SensitiveHit[] }) { +/** AI 痕迹命中 hover:词条 + 词维度,明示启发式统计口径(不暗示模型检测)。 */ +export function buildAiTraceHoverTitle(hit: AiTraceWordHit): string { + const label = AI_TRACE_WORD_DIMENSION_LABELS[hit.dimension] ?? hit.dimension; + return `「${hit.word}」${label} · 启发式统计\n建议:换成具体动作、数字或事例`; +} + +export function HighlightText({ text, hits }: { text: string; hits: HighlightHit[] }) { const segments = buildHighlightSegments(text, hits); return (

- {segments.map((seg, i) => - seg.hit ? ( + {segments.map((seg, i) => { + if (!seg.hit) return {seg.text}; + const hit = seg.hit; + if ('severity' in hit) { + return ( + + {seg.text} + + ); + } + return ( {seg.text} - ) : ( - {seg.text} - ), - )} + ); + })}

); } diff --git a/frontend/src/app/content-check/_scan-state.test.ts b/frontend/src/app/content-check/_scan-state.test.ts index c4986c5b..9632d963 100644 --- a/frontend/src/app/content-check/_scan-state.test.ts +++ b/frontend/src/app/content-check/_scan-state.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest'; import { scanPhase } from './_scan-state'; -import type { SensitiveCheckResponse } from '@/types/content-check'; +import type { AiTracesReport, SensitiveCheckResponse } from '@/types/content-check'; /** * 回归(评审阻断项):检测结果只对检测时的文稿快照有效。 * 编辑后必须判 stale——否则「未发现违禁词,可以发布」会贴着新加的极限词展示, * 旧偏移也会在新文本上错位高亮。页面渲染以 phase 为准,本测试锁住判定合同。 + * 合同对两类结果(敏感词 / AI 痕迹)一致:result 泛型化后各自都受同一快照约束。 */ const clean: SensitiveCheckResponse = { total: 0, hits: [] }; @@ -40,4 +41,18 @@ describe('scanPhase', () => { it('missing snapshot with a result is treated as stale (defensive)', () => { expect(scanPhase(null, '任意文稿', clean)).toEqual({ kind: 'stale', result: clean }); }); + + it('holds for the AI traces report payload under the same snapshot contract', () => { + const aiReport: AiTracesReport = { + level: 'high', + character_count: 12, + buzzword: { dimension: 'buzzword', hits: 2, density_per_kilochar: 166.67, level: 'high' }, + connective: { dimension: 'connective', hits: 1, density_per_kilochar: 83.33, level: 'high' }, + rhythm: { level: 'insufficient', sentence_count: 1, cv: null }, + hits: [{ word: '赋能', dimension: 'buzzword', start: 0, end: 2 }], + advice: 'AI 高频词/套话连接词密集,建议逐条改写。启发式统计,仅供参考。', + }; + expect(scanPhase('赋能闭环。', '赋能闭环。', aiReport)).toEqual({ kind: 'current', result: aiReport }); + expect(scanPhase('赋能闭环。改', '赋能闭环。', aiReport)).toEqual({ kind: 'stale', result: aiReport }); + }); }); diff --git a/frontend/src/app/content-check/_scan-state.ts b/frontend/src/app/content-check/_scan-state.ts index 693fe541..b943a099 100644 --- a/frontend/src/app/content-check/_scan-state.ts +++ b/frontend/src/app/content-check/_scan-state.ts @@ -5,21 +5,18 @@ * - 不得继续展示「未发现违禁词,可以发布」之类的把关结论(干净检测后追加极限词会结论反转); * - 不得用旧偏移在新文本上高亮(范围内错位,_highlight.tsx 的越界/重叠防御挡不住)。 * 页面渲染必须以 phase 为准:只有 current 才展示统计与高亮,stale 只提示重新检测。 + * + * result 泛型化:敏感词与 AI 痕迹两类结果共用同一快照合同(同一批请求、同一 + * 份文稿),页面把两者合并成一个对象传入。 */ -import type { SensitiveCheckResponse } from '@/types/content-check'; - -export type ScanPhase = +export type ScanPhase = | { kind: 'idle' } - | { kind: 'current'; result: SensitiveCheckResponse } - | { kind: 'stale'; result: SensitiveCheckResponse }; + | { kind: 'current'; result: T } + | { kind: 'stale'; result: T }; /** scannedText 是结果对应的文稿快照;与当前文本一致才算 current(撤销回原文同样有效)。 */ -export function scanPhase( - scannedText: string | null, - currentText: string, - result: SensitiveCheckResponse | null, -): ScanPhase { +export function scanPhase(scannedText: string | null, currentText: string, result: T | null): ScanPhase { if (!result) return { kind: 'idle' }; if (scannedText === null || scannedText !== currentText) return { kind: 'stale', result }; return { kind: 'current', result }; diff --git a/frontend/src/app/content-check/page.tsx b/frontend/src/app/content-check/page.tsx index 9dc93103..88387337 100644 --- a/frontend/src/app/content-check/page.tsx +++ b/frontend/src/app/content-check/page.tsx @@ -1,15 +1,19 @@ 'use client'; import React, { useMemo, useState } from 'react'; -import { AlertTriangle, CheckCircle2, CircleAlert, Info, Loader2, SpellCheck, Trash2 } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, CircleAlert, Info, Loader2, Sparkles, SpellCheck, Trash2 } from 'lucide-react'; import { contentCheckApi } from '@/lib/api'; import { Badge, Button, Panel, cx } from '@/components/ui'; import { ErrorState } from '@/components/StateView'; -import { HighlightText } from './_highlight'; +import { HighlightText, type HighlightHit } from './_highlight'; import { scanPhase } from './_scan-state'; import { + AI_TRACE_LEVEL_META, + AI_TRACE_WORD_DIMENSION_LABELS, SENSITIVE_CATEGORY_META, SENSITIVE_SEVERITY_LABELS, + type AiTraceLevel, + type AiTracesReport, type SensitiveCategory, type SensitiveCheckResponse, type SensitiveSeverity, @@ -32,26 +36,39 @@ const SEVERITY_CLS: Record = { low: 'text-gray-500', }; +/** 一次检测的两类结果:同一批请求、同一文稿快照,一起成败、一起过期。 */ +interface ScanResults { + sensitive: SensitiveCheckResponse; + aiTraces: AiTracesReport; +} + export default function ContentCheckPage() { const [text, setText] = useState(''); const [scanning, setScanning] = useState(false); - const [result, setResult] = useState(null); + const [results, setResults] = useState(null); /** 结果对应的文稿快照:编辑后 phase 变 stale,旧结论与旧偏移不再展示 */ const [scannedText, setScannedText] = useState(null); const [error, setError] = useState(null); - const phase = scanPhase(scannedText, text, result); + const phase = scanPhase(scannedText, text, results); + const current = phase.kind === 'current' ? phase.result : null; const stats = useMemo(() => { - if (!result) return null; + if (!current) return null; const byCategory = Object.fromEntries(CATEGORY_ORDER.map((c) => [c, 0])) as Record; const bySeverity = Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0])) as Record; - for (const hit of result.hits) { + for (const hit of current.sensitive.hits) { byCategory[hit.category] += 1; bySeverity[hit.severity] += 1; } return { byCategory, bySeverity }; - }, [result]); + }, [current]); + + /** 风险命中 + AI 痕迹命中合并渲染:红色系为风险,紫色系为写作建议。 */ + const mergedHits = useMemo(() => { + if (!current) return []; + return [...current.sensitive.hits, ...current.aiTraces.hits]; + }, [current]); const overLimit = text.length > MAX_TEXT_LENGTH; const canScan = !scanning && text.trim().length > 0 && !overLimit; @@ -60,11 +77,14 @@ export default function ContentCheckPage() { setScanning(true); setError(null); try { - const response = await contentCheckApi.checkSensitive(text); - setResult(response); + const [sensitive, aiTraces] = await Promise.all([ + contentCheckApi.checkSensitive(text), + contentCheckApi.checkAiTraces(text), + ]); + setResults({ sensitive, aiTraces }); setScannedText(text); } catch (err) { - setResult(null); + setResults(null); setScannedText(null); setError(err instanceof Error ? err.message : '检测失败,请稍后重试'); } finally { @@ -74,17 +94,21 @@ export default function ContentCheckPage() { function handleClear() { setText(''); - setResult(null); + setResults(null); setScannedText(null); setError(null); } + const sensitiveTotal = current?.sensitive.total ?? 0; + const hasAnyHit = mergedHits.length > 0; + return (

文稿检测

- 发布前粘贴文稿,检测广告法极限词、高危违禁词与平台限流词。命中原文高亮显示,悬停查看替换建议; + 发布前粘贴文稿,检测广告法极限词、高危违禁词与平台限流词;另附 AI 痕迹启发式统计 + (AI 高频词、套话连接词、句式节奏),仅供改稿参考。命中原文高亮显示,悬停查看建议; 文稿只在本次检测中使用,不落库、不留存。

@@ -116,7 +140,7 @@ export default function ContentCheckPage() { {overLimit && ' · 超出上限,请删减后再检测'}
-