Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/agents/ai-doc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,7 @@ txt/md/markdown 自 dev-board#37 起不进 LOWA(前端走 PlainTextEditor.vue
- **枚举列宽**:`evidence_link_target.method` 是 varchar(32) —— 枚举里最长的 `written_statement` 有 17 个字符,原来的 16 让「书面确认」型链接**永远存不进去**(真实起草跑分报 `Value too long for column method`)。改枚举时一并核对列宽,回归测试 `EvidenceLinkServiceTest.enumColumnsAreWideEnoughForEveryAllowedValue` 钉住。

- **worker 失败原因不能丢**:`agentClientActions.handleEditorCommand` 回传失败时 `error` 缺省要退到 `message` —— worker 里大量失败分支只填 `message`(`delete_match` 的「match index out of range」之类),只取 `error` 的话模型收到 `{"error": "null"}`,只能瞎猜着重试。回归用例 `frontend/tests/project-home/editor-command-failure-reason.test.mjs`。

## 即时审校的范围契约(dev-board#547)

新增 editor action `get_review_context`、`goto_review_range`、`apply_review_edit` 服务用户审校卡片,不新增 AI 工具。范围由 `revision + paragraphIndex(0 基)+ start/end(UTF-16)+ expectedParagraph + quote` 联合校验,禁止从过期提示直接改正文;修订采用与单步撤销复用现有基础设施。`ContractStructureAudit.Report.findingsTruncated` 表示任一规则结果超限,不能把截断报告当完整报告;正文编号检查遇被跳过的段落索引要断开连续性推断。完整契约与测试见 doc-editor/doc-insight。
12 changes: 12 additions & 0 deletions .claude/agents/doc-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,15 @@ HOUSE 不再是常量:`buildHouse(profile)` 从画像 JSON 派生写端常量

- 离开工作台/退出登录前,`flushDirtyEditors` 逐个保存后必须同步重扫当前 Office/文本注册表;保存 B 期间重新编辑 A、原本干净实例变脏、或新注册实例变脏都应阻止导航(`flush-dirty-editors.test.mjs` 时序用例),不能只相信每个实例刚保存时的状态。
- 文本标签关闭同样必须检查 `flushSave` 返回值及最终 dirty/saving;失败仅提示原有重试入口并保留标签。`PlainTextEditor.flushSave` 不得吞掉 `save()` 的 false,也不能在保存期间新增输入后报全已保存。

## 即时审校(dev-board#547)

`inlineReviewHost.js` 读当前 Writer 正文快照,修改后 1.2 秒防抖调 `/insight/review`(`deep:false`),只有点击「深入审校」才传 `deep:true`。会话与 worker revision 双围栏拦住迟到结果;修改、换文档、销毁立即失效。同用户开关同步,独立于写作补全开关。默认计时器用箭头包装调用,不能以 `{set:setTimeout}.set()` 调浏览器原生函数(Illegal invocation,Node 单测捕获不了);真实浏览器测试覆盖启动与销毁。

`zetaOfficeInlineReview.js` 是不落盘的 guest DOM:当前段落光标旁提示、全量问题清单、原文定位、明确点击采用。不给文档塞书签或批注。IME/编辑/滚动立即隐藏旧定位;**LOWA boot 每秒发同尺寸 synthetic resize,不能因此清掉提示;只有视口或 canvas 几何变化才失效**。不抢 Tab;候选菜单打开时让位。

worker `get_document_text` 与 `get_review_context` 回 revision;`goto_review_range` / `apply_review_edit` 校验 revision、0 基段落、UTF-16 起止、完整段落和引文后操作临时 range。采用建议保留细粒度修订、一次撤销,旧结果拒绝。导出不增 revision,重载即使同文也增。`get_cursor_rect.viewData` 只返回可序列化原始值。

检查范围为正文段落(不含表格、页眉页脚),单段 >15,000 字跳过并披露截断,总计 200,000 字/10,000 段/60 页。行内修订模式需切页边或最终视图。完整在线核验保留在依据窗格,打开窗格不自动调用模型或外库,点击后先保存对应文档。

验证:`npm run test:inline-review`、`test:lowa-inline-review`,真实桌面 `tests/desktop-e2e/writing.mjs` 同时检查中文补全与刚输入正文的规则提示、无自动深入审校/外查。
16 changes: 16 additions & 0 deletions .claude/agents/doc-insight.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,22 @@ cd frontend && npm run build:h5 && npm run build:zetaoffice # 改 editor-main.

## 验证

## 即时审校(dev-board#547)

`POST /api/projects/{projectId}/insight/review` 接收当前未保存的正文段落
`{docFileId, paragraphs:[{index,text}], deep, truncated}`,返回
`{findings, summary, truncated, scope:"body", deep}`。段落 index 为 0 基;finding 带当前段落全文
`expectedParagraph` 和定位区间,宿主必须按同一编辑器 revision 接收结果,过期响应直接丢弃。

- `deep:false` 只运行 `ContractStructureAudit` 的字形、编号、交叉引用、待定内容、同段算术规则,
以及 `DocInsightChecks.usccIssues`。不调模型、不查外部库、不写 insight run/entity/finding 表。
- `deep:true` 只能由有写权限的用户点击“深入审校”触发:辅助模型在一次分块调用中抽取 claims
和有两条逐字引文支撑的主体/权利义务/条件/日期疑点、记 token 用量,再由
`DocInsightChecks.countMismatches` 做确定性数量矛盾判定。同一文档单飞,仍不做工商、法规、案例或引用外查;
坏 JSON 会令 `summary.deepComplete=false`,不得显示成“未发现问题”。
- scope 仅为 Writer 正文;表格、页眉页脚等当前拿不到可靠段落的内容,继续由保存文件后的专门核验覆盖。
- 即时 finding 只陈述机械事实;重复或已过期的模型 quote 不下发定位结果,也不得自动改文档。

```
cd backend && mvn test -Dtest='DocInsight*,LawArticle*' # 70 条
cd backend && mvn test -Dtest='*Mcp*Test' # 含 StreamableHttpMcpProviderCredentialTest(空凭证不发请求)
Expand Down
2 changes: 2 additions & 0 deletions .claude/agents/eng-infra.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,3 +459,5 @@ stash 误 pop 之类)之后,5174 上的页面可能变成**整页无样式**

- 2026-09-07 桌面剪贴板回归:`test:desktop-e2e` 覆盖系统复制→IPC采集→卡片→确认删除→数据库消失,原剪贴板仅暂存内存并条件恢复。macOS CDP键盘模拟要在KeyA/KeyC附带`commands: [selectAll]/[copy]`,否则修饰键发送成功但原生编辑动作不执行(选区仍0),会伪报采集失败。
- 全应用J2/J11按已恢复的团队案件库取回入口走真实鼠标;不再以旧SHOW_CLOUD_ACCEPT=false口径断言或强开组件状态。

- 即时审校(dev-board#547):CI `test:inline-review` 覆盖宿主版本围栏、手动 AI 与 guest 中英文交互;`test:lowa-inline-review` 使用原生浏览器计时器并验证真实定位/修订/撤销,桌面 `tests/desktop-e2e/writing.mjs` 验证中文输入后本地规则自动启动。
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ jobs:
- name: Writing assistance contracts (dev-board#538)
working-directory: frontend
run: npm run test:completion
- name: Inline review contracts (dev-board#547)
working-directory: frontend
run: npm run test:inline-review
- name: Optional components prompts (dev-board#530)
working-directory: frontend
run: npm run test:optional-components
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import com.checkba.service.insight.DocInsightViews.EntityView;
import com.checkba.service.insight.DocInsightViews.InsightView;
import com.checkba.service.insight.DocInsightViews.StartResult;
import com.checkba.service.insight.InlineReviewViews.ParagraphInput;
import com.checkba.service.insight.InlineReviewViews.ReviewResult;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
Expand Down Expand Up @@ -71,8 +73,26 @@ public EntityView refresh(@PathVariable Long projectId,
return svc.refreshEntity(uid(sessionId), projectId, entityId);
}

/** Review the current unsaved Writer body. Local mode never calls a model or an external source. */
@PostMapping("/review")
public ReviewResult review(@PathVariable Long projectId,
@RequestBody ReviewReq req,
@RequestHeader(value = "X-Session-Id", required = false) String sessionId) {
if (req == null) throw new IllegalArgumentException("review body required");
return svc.review(uid(sessionId), projectId, req.getDocFileId(), req.getParagraphs(),
req.isDeep(), req.isTruncated());
}

@Data
public static class ParseReq {
private Long docFileId;
}

@Data
public static class ReviewReq {
private Long docFileId;
private java.util.List<ParagraphInput> paragraphs;
private boolean deep;
private boolean truncated;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public static final class Report {
public final Map<String, Integer> revisionsByType = new LinkedHashMap<>();
public final List<String> largeDeletions = new ArrayList<>();
public final List<String> revisionSample = new ArrayList<>();
public boolean findingsTruncated;
public int totalParagraphs;
public int totalRevisions;
public String revisionNote;
Expand Down Expand Up @@ -158,6 +159,11 @@ private static String scriptLabel(String s) {
}
}

private static void addLimited(Report report, List<Finding> target, int limit, Finding finding) {
if (target.size() < limit) target.add(finding);
else report.findingsTruncated = true;
}

// ------------------------------------------------------------------ 入口

public static Report run(List<Paragraph> paragraphs, List<Revision> revisions, String revisionNote) {
Expand Down Expand Up @@ -223,12 +229,12 @@ private static void auditScript(List<Paragraph> paras, Report r) {
if ("mixed".equals(r.dominantScript)) return;

boolean trad = "traditional".equals(r.dominantScript);
for (int i = 0; i < paras.size() && r.scriptOutliers.size() < 30; i++) {
for (int i = 0; i < paras.size(); i++) {
int mine = trad ? per[i][0] : per[i][1];
int other = trad ? per[i][1] : per[i][0];
// 判据:异体字至少 2 个,且多于本体字——一两个错别字不报,整句混入才报
if (other >= 2 && other > mine) {
r.scriptOutliers.add(new Finding(paras.get(i).index(),
addLimited(r, r.scriptOutliers, 30, new Finding(paras.get(i).index(),
(trad ? "简体字混入繁體正文" : "繁體字混入简体正文") + "(异体字 " + other
+ " 个):" + snippet(paras.get(i).text(), 60)));
}
Expand Down Expand Up @@ -266,7 +272,14 @@ private static void auditNumbering(List<Paragraph> paras, Report r) {
int lastTiaoParagraph = -1;
boolean hasTiao = paras.stream().anyMatch(p -> P_TIAO.matcher(p.text()).find());
Set<Integer> seenTiao = new LinkedHashSet<>();
int previousParagraph = -1;
for (Paragraph p : paras) {
if (previousParagraph >= 0 && p.index() != previousParagraph + 1) {
// An omitted paragraph may contain a heading or restart a numbered list.
runs.clear(); seenTiao.clear(); lastTiao = 0; lastTiaoParagraph = -1;
scope0++; scope1++;
}
previousParagraph = p.index();
String t = p.text();
Matcher m;
if ((m = P_TIAO.matcher(t)).find()) {
Expand Down Expand Up @@ -329,6 +342,7 @@ private static void auditNumbering(List<Paragraph> paras, Report r) {
}
}
if (r.numbering.size() > 40) {
r.findingsTruncated = true;
List<Finding> cut = new ArrayList<>(r.numbering.subList(0, 40));
r.numbering.clear();
r.numbering.addAll(cut);
Expand Down Expand Up @@ -387,29 +401,29 @@ private static void auditCrossReferences(List<Paragraph> paras, Report r) {
Matcher h = P_TIAO.matcher(t);
if (h.find()) headerEnd = h.end();
Matcher m = P_REF_TIAO.matcher(t);
while (m.find() && r.danglingReferences.size() < 40) {
while (m.find()) {
if (m.start() < headerEnd) continue;
int n = ChineseNumerals.parse(m.group(1));
if (n <= 0 || tiao.isEmpty()) continue;
String ref = m.group().replaceAll("\\s+", "");
if (!tiao.contains(n)) {
if (reported.add("t" + n)) {
r.danglingReferences.add(new Finding(p.index(), "引用了「" + ref + "」,但全文没有第" + n + "条"));
addLimited(r, r.danglingReferences, 40, new Finding(p.index(), "引用了「" + ref + "」,但全文没有第" + n + "条"));
}
} else if (m.group(2) != null && !dotted.isEmpty() && !dotted.contains(n + "." + m.group(2))) {
String key = n + "." + m.group(2);
if (reported.add("d" + key)) {
r.danglingReferences.add(new Finding(p.index(), "引用了「" + ref + "」,但全文没有编号 " + key + " 的款"));
addLimited(r, r.danglingReferences, 40, new Finding(p.index(), "引用了「" + ref + "」,但全文没有编号 " + key + " 的款"));
}
}
}
if (annexes.isEmpty()) continue;
Matcher a = P_REF_ANNEX.matcher(t);
while (a.find() && r.danglingReferences.size() < 40) {
while (a.find()) {
if (a.start() == 0 || P_DEF_ANNEX.matcher(t).find()) continue;
String key = a.group(1).charAt(0) + normalizeAnnex(a.group(2));
if (!annexes.contains(key) && reported.add("a" + key)) {
r.danglingReferences.add(new Finding(p.index(), "引用了「" + a.group().trim() + "」,但全文没有以它开头的附表/附件标题"));
addLimited(r, r.danglingReferences, 40, new Finding(p.index(), "引用了「" + a.group().trim() + "」,但全文没有以它开头的附表/附件标题"));
}
}
}
Expand All @@ -429,8 +443,8 @@ private static String normalizeAnnex(String s) {
private static void auditBlanks(List<Paragraph> paras, Report r) {
for (Paragraph p : paras) {
Matcher m = P_BLANK.matcher(p.text());
while (m.find() && r.blanks.size() < 40) {
r.blanks.add(new Finding(p.index(), "「" + m.group() + "」:" + around(p.text(), m.start(), m.end(), 30)));
while (m.find()) {
addLimited(r, r.blanks, 40, new Finding(p.index(), "「" + m.group() + "」:" + around(p.text(), m.start(), m.end(), 30)));
}
}
}
Expand Down
Loading
Loading