feat: export core Hermes skills
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
skills/.curator_backups/
|
||||||
|
skills/.hub/
|
||||||
|
skills/.archive/
|
||||||
|
skills/.usage.json
|
||||||
|
skills/.usage.json.lock
|
||||||
|
skills/.bundled_manifest
|
||||||
|
skills/.curator_state
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
# hermes-skills
|
# hermes-skills
|
||||||
|
|
||||||
Public export of Hermes skills used by 小Maggie
|
Public export of Hermes skills used by 小Maggie.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
- `skills/` — exported skill directories and linked reference/template/script files.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Exported from local Hermes profile.
|
||||||
|
- Skills may contain workflow-specific conventions and environment assumptions.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
description: Skills for spawning and orchestrating autonomous AI coding agents and multi-agent workflows — running independent agent processes, delegating tasks, and coordinating parallel workstreams.
|
||||||
|
---
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
name: ai-coding-agents
|
||||||
|
description: "Delegate coding to external AI CLI agents (Claude Code, Codex, OpenCode). Shared orchestration patterns, tool selection, PTY handling."
|
||||||
|
version: 1.0.0
|
||||||
|
author: Hermes Agent
|
||||||
|
license: MIT
|
||||||
|
platforms: [linux, macos, windows]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [Coding-Agent, Claude, Codex, OpenAI, OpenCode, Autonomous, PTY, Delegation]
|
||||||
|
related_skills: [hermes-agent]
|
||||||
|
---
|
||||||
|
|
||||||
|
# AI Coding Agents — Orchestration Guide
|
||||||
|
|
||||||
|
Delegate coding tasks to external autonomous coding agent CLIs via the Hermes terminal. All three tools follow the same orchestration patterns; pick based on what's installed and the user's preference.
|
||||||
|
|
||||||
|
## Tool Selection
|
||||||
|
|
||||||
|
| Tool | Provider | Install | Best For |
|
||||||
|
|------|----------|---------|----------|
|
||||||
|
| **Claude Code** | Anthropic | `npm install -g @anthropic-ai/claude-code` | Complex multi-file refactoring, deep reasoning, MCP integration |
|
||||||
|
| **Codex** | OpenAI | `npm install -g @openai/codex` | Fast feature implementation, batch issue fixing |
|
||||||
|
| **OpenCode** | Multi-provider | `npm i -g opencode-ai@latest` | Provider-agnostic work, cost optimization |
|
||||||
|
|
||||||
|
## Shared Orchestration Patterns
|
||||||
|
|
||||||
|
### One-Shot (Preferred for most tasks)
|
||||||
|
|
||||||
|
All three tools support non-interactive one-shot execution — the cleanest integration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Claude Code
|
||||||
|
claude -p 'Add error handling to all API calls in src/' --allowedTools 'Read,Edit' --max-turns 10
|
||||||
|
|
||||||
|
# Codex
|
||||||
|
codex exec 'Add dark mode toggle to settings'
|
||||||
|
|
||||||
|
# OpenCode
|
||||||
|
opencode run 'Add retry logic to API calls and update tests'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interactive / Background (Multi-turn sessions)
|
||||||
|
|
||||||
|
For iterative work, run in background with PTY:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All tools require pty=true for interactive mode
|
||||||
|
terminal(command="<tool> ...", workdir="~/project", background=true, pty=true)
|
||||||
|
# Monitor with process(action="poll"|"log")
|
||||||
|
# Send input with process(action="submit", data="...")
|
||||||
|
```
|
||||||
|
|
||||||
|
### PR Review
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Claude Code
|
||||||
|
claude -p 'Review this PR thoroughly' --from-pr 42 --max-turns 10
|
||||||
|
|
||||||
|
# Codex
|
||||||
|
codex exec 'Review PR #42. git diff origin/main...origin/pr/42'
|
||||||
|
|
||||||
|
# OpenCode
|
||||||
|
opencode pr 42
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parallel Tasks
|
||||||
|
|
||||||
|
All three support running multiple instances in separate workdirs/worktrees:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
terminal(command="<tool> 'Fix issue #78'", workdir="/tmp/issue-78", background=true, pty=true)
|
||||||
|
terminal(command="<tool> 'Fix issue #99'", workdir="/tmp/issue-99", background=true, pty=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
1. **Always use `workdir`** — scope the agent to the right project
|
||||||
|
2. **Set turn limits** for one-shot mode (prevents runaway loops)
|
||||||
|
3. **Use `pty=true`** for interactive sessions — all three are TUI apps
|
||||||
|
4. **Monitor progress** with `process(action="poll"|"log")` — don't kill slow sessions
|
||||||
|
5. **Git repo required** — all three need a git directory. Use `mktemp -d && git init` for scratch work
|
||||||
|
6. **Clean up** background sessions when done
|
||||||
|
|
||||||
|
## Tool-Specific References
|
||||||
|
|
||||||
|
Detailed CLI flags, session management, and advanced features for each tool:
|
||||||
|
|
||||||
|
- `references/claude-code.md` — Claude Code: print mode deep dive, tmux orchestration, hooks, MCP, subagents, settings hierarchy
|
||||||
|
- `references/codex.md` — Codex: exec flags, full-auto vs yolo, worktree patterns, auth (OAuth vs API key)
|
||||||
|
- `references/opencode.md` — OpenCode: run vs interactive TUI, session resumption, provider selection, PR review
|
||||||
@@ -0,0 +1,745 @@
|
|||||||
|
---
|
||||||
|
name: claude-code
|
||||||
|
description: "Delegate coding to Claude Code CLI (features, PRs)."
|
||||||
|
version: 2.2.0
|
||||||
|
author: Hermes Agent + Teknium
|
||||||
|
license: MIT
|
||||||
|
platforms: [linux, macos, windows]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [Coding-Agent, Claude, Anthropic, Code-Review, Refactoring, PTY, Automation]
|
||||||
|
related_skills: [codex, hermes-agent, opencode]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Claude Code — Hermes Orchestration Guide
|
||||||
|
|
||||||
|
Delegate coding tasks to [Claude Code](https://code.claude.com/docs/en/cli-reference) (Anthropic's autonomous coding agent CLI) via the Hermes terminal. Claude Code v2.x can read files, write code, run shell commands, spawn subagents, and manage git workflows autonomously.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Install:** `npm install -g @anthropic-ai/claude-code`
|
||||||
|
- **Auth:** run `claude` once to log in (browser OAuth for Pro/Max, or set `ANTHROPIC_API_KEY`)
|
||||||
|
- **Console auth:** `claude auth login --console` for API key billing
|
||||||
|
- **SSO auth:** `claude auth login --sso` for Enterprise
|
||||||
|
- **Check status:** `claude auth status` (JSON) or `claude auth status --text` (human-readable)
|
||||||
|
- **Health check:** `claude doctor` — checks auto-updater and installation health
|
||||||
|
- **Version check:** `claude --version` (requires v2.x+)
|
||||||
|
- **Update:** `claude update` or `claude upgrade`
|
||||||
|
|
||||||
|
## Two Orchestration Modes
|
||||||
|
|
||||||
|
Hermes interacts with Claude Code in two fundamentally different ways. Choose based on the task.
|
||||||
|
|
||||||
|
### Mode 1: Print Mode (`-p`) — Non-Interactive (PREFERRED for most tasks)
|
||||||
|
|
||||||
|
Print mode runs a one-shot task, returns the result, and exits. No PTY needed. No interactive prompts. This is the cleanest integration path.
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="claude -p 'Add error handling to all API calls in src/' --allowedTools 'Read,Edit' --max-turns 10", workdir="/path/to/project", timeout=120)
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use print mode:**
|
||||||
|
- One-shot coding tasks (fix a bug, add a feature, refactor)
|
||||||
|
- CI/CD automation and scripting
|
||||||
|
- Structured data extraction with `--json-schema`
|
||||||
|
- Piped input processing (`cat file | claude -p "analyze this"`)
|
||||||
|
- Any task where you don't need multi-turn conversation
|
||||||
|
|
||||||
|
**Print mode skips ALL interactive dialogs** — no workspace trust prompt, no permission confirmations. This makes it ideal for automation.
|
||||||
|
|
||||||
|
### Mode 2: Interactive PTY via tmux — Multi-Turn Sessions
|
||||||
|
|
||||||
|
Interactive mode gives you a full conversational REPL where you can send follow-up prompts, use slash commands, and watch Claude work in real time. **Requires tmux orchestration.**
|
||||||
|
|
||||||
|
```
|
||||||
|
# Start a tmux session
|
||||||
|
terminal(command="tmux new-session -d -s claude-work -x 140 -y 40")
|
||||||
|
|
||||||
|
# Launch Claude Code inside it
|
||||||
|
terminal(command="tmux send-keys -t claude-work 'cd /path/to/project && claude' Enter")
|
||||||
|
|
||||||
|
# Wait for startup, then send your task
|
||||||
|
# (after ~3-5 seconds for the welcome screen)
|
||||||
|
terminal(command="sleep 5 && tmux send-keys -t claude-work 'Refactor the auth module to use JWT tokens' Enter")
|
||||||
|
|
||||||
|
# Monitor progress by capturing the pane
|
||||||
|
terminal(command="sleep 15 && tmux capture-pane -t claude-work -p -S -50")
|
||||||
|
|
||||||
|
# Send follow-up tasks
|
||||||
|
terminal(command="tmux send-keys -t claude-work 'Now add unit tests for the new JWT code' Enter")
|
||||||
|
|
||||||
|
# Exit when done
|
||||||
|
terminal(command="tmux send-keys -t claude-work '/exit' Enter")
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use interactive mode:**
|
||||||
|
- Multi-turn iterative work (refactor → review → fix → test cycle)
|
||||||
|
- Tasks requiring human-in-the-loop decisions
|
||||||
|
- Exploratory coding sessions
|
||||||
|
- When you need to use Claude's slash commands (`/compact`, `/review`, `/model`)
|
||||||
|
|
||||||
|
## PTY Dialog Handling (CRITICAL for Interactive Mode)
|
||||||
|
|
||||||
|
Claude Code presents up to two confirmation dialogs on first launch. You MUST handle these via tmux send-keys:
|
||||||
|
|
||||||
|
### Dialog 1: Workspace Trust (first visit to a directory)
|
||||||
|
```
|
||||||
|
❯ 1. Yes, I trust this folder ← DEFAULT (just press Enter)
|
||||||
|
2. No, exit
|
||||||
|
```
|
||||||
|
**Handling:** `tmux send-keys -t <session> Enter` — default selection is correct.
|
||||||
|
|
||||||
|
### Dialog 2: Bypass Permissions Warning (only with --dangerously-skip-permissions)
|
||||||
|
```
|
||||||
|
❯ 1. No, exit ← DEFAULT (WRONG choice!)
|
||||||
|
2. Yes, I accept
|
||||||
|
```
|
||||||
|
**Handling:** Must navigate DOWN first, then Enter:
|
||||||
|
```
|
||||||
|
tmux send-keys -t <session> Down && sleep 0.3 && tmux send-keys -t <session> Enter
|
||||||
|
```
|
||||||
|
|
||||||
|
### Robust Dialog Handling Pattern
|
||||||
|
```
|
||||||
|
# Launch with permissions bypass
|
||||||
|
terminal(command="tmux send-keys -t claude-work 'claude --dangerously-skip-permissions \"your task\"' Enter")
|
||||||
|
|
||||||
|
# Handle trust dialog (Enter for default "Yes")
|
||||||
|
terminal(command="sleep 4 && tmux send-keys -t claude-work Enter")
|
||||||
|
|
||||||
|
# Handle permissions dialog (Down then Enter for "Yes, I accept")
|
||||||
|
terminal(command="sleep 3 && tmux send-keys -t claude-work Down && sleep 0.3 && tmux send-keys -t claude-work Enter")
|
||||||
|
|
||||||
|
# Now wait for Claude to work
|
||||||
|
terminal(command="sleep 15 && tmux capture-pane -t claude-work -p -S -60")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** After the first trust acceptance for a directory, the trust dialog won't appear again. Only the permissions dialog recurs each time you use `--dangerously-skip-permissions`.
|
||||||
|
|
||||||
|
## CLI Subcommands
|
||||||
|
|
||||||
|
| Subcommand | Purpose |
|
||||||
|
|------------|---------|
|
||||||
|
| `claude` | Start interactive REPL |
|
||||||
|
| `claude "query"` | Start REPL with initial prompt |
|
||||||
|
| `claude -p "query"` | Print mode (non-interactive, exits when done) |
|
||||||
|
| `cat file \| claude -p "query"` | Pipe content as stdin context |
|
||||||
|
| `claude -c` | Continue the most recent conversation in this directory |
|
||||||
|
| `claude -r "id"` | Resume a specific session by ID or name |
|
||||||
|
| `claude auth login` | Sign in (add `--console` for API billing, `--sso` for Enterprise) |
|
||||||
|
| `claude auth status` | Check login status (returns JSON; `--text` for human-readable) |
|
||||||
|
| `claude mcp add <name> -- <cmd>` | Add an MCP server |
|
||||||
|
| `claude mcp list` | List configured MCP servers |
|
||||||
|
| `claude mcp remove <name>` | Remove an MCP server |
|
||||||
|
| `claude agents` | List configured agents |
|
||||||
|
| `claude doctor` | Run health checks on installation and auto-updater |
|
||||||
|
| `claude update` / `claude upgrade` | Update Claude Code to latest version |
|
||||||
|
| `claude remote-control` | Start server to control Claude from claude.ai or mobile app |
|
||||||
|
| `claude install [target]` | Install native build (stable, latest, or specific version) |
|
||||||
|
| `claude setup-token` | Set up long-lived auth token (requires subscription) |
|
||||||
|
| `claude plugin` / `claude plugins` | Manage Claude Code plugins |
|
||||||
|
| `claude auto-mode` | Inspect auto mode classifier configuration |
|
||||||
|
|
||||||
|
## Print Mode Deep Dive
|
||||||
|
|
||||||
|
### Structured JSON Output
|
||||||
|
```
|
||||||
|
terminal(command="claude -p 'Analyze auth.py for security issues' --output-format json --max-turns 5", workdir="/project", timeout=120)
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns a JSON object with:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "result",
|
||||||
|
"subtype": "success",
|
||||||
|
"result": "The analysis text...",
|
||||||
|
"session_id": "75e2167f-...",
|
||||||
|
"num_turns": 3,
|
||||||
|
"total_cost_usd": 0.0787,
|
||||||
|
"duration_ms": 10276,
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"terminal_reason": "completed",
|
||||||
|
"usage": { "input_tokens": 5, "output_tokens": 603, ... },
|
||||||
|
"modelUsage": { "claude-sonnet-4-6": { "costUSD": 0.078, "contextWindow": 200000 } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key fields:** `session_id` for resumption, `num_turns` for agentic loop count, `total_cost_usd` for spend tracking, `subtype` for success/error detection (`success`, `error_max_turns`, `error_budget`).
|
||||||
|
|
||||||
|
### Streaming JSON Output
|
||||||
|
For real-time token streaming, use `stream-json` with `--verbose`:
|
||||||
|
```
|
||||||
|
terminal(command="claude -p 'Write a summary' --output-format stream-json --verbose --include-partial-messages", timeout=60)
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns newline-delimited JSON events. Filter with jq for live text:
|
||||||
|
```
|
||||||
|
claude -p "Explain X" --output-format stream-json --verbose --include-partial-messages | \
|
||||||
|
jq -rj 'select(.type == "stream_event" and .event.delta.type? == "text_delta") | .event.delta.text'
|
||||||
|
```
|
||||||
|
|
||||||
|
Stream events include `system/api_retry` with `attempt`, `max_retries`, and `error` fields (e.g., `rate_limit`, `billing_error`).
|
||||||
|
|
||||||
|
### Bidirectional Streaming
|
||||||
|
For real-time input AND output streaming:
|
||||||
|
```
|
||||||
|
claude -p "task" --input-format stream-json --output-format stream-json --replay-user-messages
|
||||||
|
```
|
||||||
|
`--replay-user-messages` re-emits user messages on stdout for acknowledgment.
|
||||||
|
|
||||||
|
### Piped Input
|
||||||
|
```
|
||||||
|
# Pipe a file for analysis
|
||||||
|
terminal(command="cat src/auth.py | claude -p 'Review this code for bugs' --max-turns 1", timeout=60)
|
||||||
|
|
||||||
|
# Pipe multiple files
|
||||||
|
terminal(command="cat src/*.py | claude -p 'Find all TODO comments' --max-turns 1", timeout=60)
|
||||||
|
|
||||||
|
# Pipe command output
|
||||||
|
terminal(command="git diff HEAD~3 | claude -p 'Summarize these changes' --max-turns 1", timeout=60)
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Schema for Structured Extraction
|
||||||
|
```
|
||||||
|
terminal(command="claude -p 'List all functions in src/' --output-format json --json-schema '{\"type\":\"object\",\"properties\":{\"functions\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"functions\"]}' --max-turns 5", workdir="/project", timeout=90)
|
||||||
|
```
|
||||||
|
|
||||||
|
Parse `structured_output` from the JSON result. Claude validates output against the schema before returning.
|
||||||
|
|
||||||
|
### Session Continuation
|
||||||
|
```
|
||||||
|
# Start a task
|
||||||
|
terminal(command="claude -p 'Start refactoring the database layer' --output-format json --max-turns 10 > /tmp/session.json", workdir="/project", timeout=180)
|
||||||
|
|
||||||
|
# Resume with session ID
|
||||||
|
terminal(command="claude -p 'Continue and add connection pooling' --resume $(cat /tmp/session.json | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"session_id\"])') --max-turns 5", workdir="/project", timeout=120)
|
||||||
|
|
||||||
|
# Or resume the most recent session in the same directory
|
||||||
|
terminal(command="claude -p 'What did you do last time?' --continue --max-turns 1", workdir="/project", timeout=30)
|
||||||
|
|
||||||
|
# Fork a session (new ID, keeps history)
|
||||||
|
terminal(command="claude -p 'Try a different approach' --resume <id> --fork-session --max-turns 10", workdir="/project", timeout=120)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bare Mode for CI/Scripting
|
||||||
|
```
|
||||||
|
terminal(command="claude --bare -p 'Run all tests and report failures' --allowedTools 'Read,Bash' --max-turns 10", workdir="/project", timeout=180)
|
||||||
|
```
|
||||||
|
|
||||||
|
`--bare` skips hooks, plugins, MCP discovery, and CLAUDE.md loading. Fastest startup. Requires `ANTHROPIC_API_KEY` (skips OAuth).
|
||||||
|
|
||||||
|
To selectively load context in bare mode:
|
||||||
|
| To load | Flag |
|
||||||
|
|---------|------|
|
||||||
|
| System prompt additions | `--append-system-prompt "text"` or `--append-system-prompt-file path` |
|
||||||
|
| Settings | `--settings <file-or-json>` |
|
||||||
|
| MCP servers | `--mcp-config <file-or-json>` |
|
||||||
|
| Custom agents | `--agents '<json>'` |
|
||||||
|
|
||||||
|
### Fallback Model for Overload
|
||||||
|
```
|
||||||
|
terminal(command="claude -p 'task' --fallback-model haiku --max-turns 5", timeout=90)
|
||||||
|
```
|
||||||
|
Automatically falls back to the specified model when the default is overloaded (print mode only).
|
||||||
|
|
||||||
|
## Complete CLI Flags Reference
|
||||||
|
|
||||||
|
### Session & Environment
|
||||||
|
| Flag | Effect |
|
||||||
|
|------|--------|
|
||||||
|
| `-p, --print` | Non-interactive one-shot mode (exits when done) |
|
||||||
|
| `-c, --continue` | Resume most recent conversation in current directory |
|
||||||
|
| `-r, --resume <id>` | Resume specific session by ID or name (interactive picker if no ID) |
|
||||||
|
| `--fork-session` | When resuming, create new session ID instead of reusing original |
|
||||||
|
| `--session-id <uuid>` | Use a specific UUID for the conversation |
|
||||||
|
| `--no-session-persistence` | Don't save session to disk (print mode only) |
|
||||||
|
| `--add-dir <paths...>` | Grant Claude access to additional working directories |
|
||||||
|
| `-w, --worktree [name]` | Run in an isolated git worktree at `.claude/worktrees/<name>` |
|
||||||
|
| `--tmux` | Create a tmux session for the worktree (requires `--worktree`) |
|
||||||
|
| `--ide` | Auto-connect to a valid IDE on startup |
|
||||||
|
| `--chrome` / `--no-chrome` | Enable/disable Chrome browser integration for web testing |
|
||||||
|
| `--from-pr [number]` | Resume session linked to a specific GitHub PR |
|
||||||
|
| `--file <specs...>` | File resources to download at startup (format: `file_id:relative_path`) |
|
||||||
|
|
||||||
|
### Model & Performance
|
||||||
|
| Flag | Effect |
|
||||||
|
|------|--------|
|
||||||
|
| `--model <alias>` | Model selection: `sonnet`, `opus`, `haiku`, or full name like `claude-sonnet-4-6` |
|
||||||
|
| `--effort <level>` | Reasoning depth: `low`, `medium`, `high`, `max`, `auto` | Both |
|
||||||
|
| `--max-turns <n>` | Limit agentic loops (print mode only; prevents runaway) |
|
||||||
|
| `--max-budget-usd <n>` | Cap API spend in dollars (print mode only) |
|
||||||
|
| `--fallback-model <model>` | Auto-fallback when default model is overloaded (print mode only) |
|
||||||
|
| `--betas <betas...>` | Beta headers to include in API requests (API key users only) |
|
||||||
|
|
||||||
|
### Permission & Safety
|
||||||
|
| Flag | Effect |
|
||||||
|
|------|--------|
|
||||||
|
| `--dangerously-skip-permissions` | Auto-approve ALL tool use (file writes, bash, network, etc.) |
|
||||||
|
| `--allow-dangerously-skip-permissions` | Enable bypass as an *option* without enabling it by default |
|
||||||
|
| `--permission-mode <mode>` | `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions` |
|
||||||
|
| `--allowedTools <tools...>` | Whitelist specific tools (comma or space-separated) |
|
||||||
|
| `--disallowedTools <tools...>` | Blacklist specific tools |
|
||||||
|
| `--tools <tools...>` | Override built-in tool set (`""` = none, `"default"` = all, or tool names) |
|
||||||
|
|
||||||
|
### Output & Input Format
|
||||||
|
| Flag | Effect |
|
||||||
|
|------|--------|
|
||||||
|
| `--output-format <fmt>` | `text` (default), `json` (single result object), `stream-json` (newline-delimited) |
|
||||||
|
| `--input-format <fmt>` | `text` (default) or `stream-json` (real-time streaming input) |
|
||||||
|
| `--json-schema <schema>` | Force structured JSON output matching a schema |
|
||||||
|
| `--verbose` | Full turn-by-turn output |
|
||||||
|
| `--include-partial-messages` | Include partial message chunks as they arrive (stream-json + print) |
|
||||||
|
| `--replay-user-messages` | Re-emit user messages on stdout (stream-json bidirectional) |
|
||||||
|
|
||||||
|
### System Prompt & Context
|
||||||
|
| Flag | Effect |
|
||||||
|
|------|--------|
|
||||||
|
| `--append-system-prompt <text>` | **Add** to the default system prompt (preserves built-in capabilities) |
|
||||||
|
| `--append-system-prompt-file <path>` | **Add** file contents to the default system prompt |
|
||||||
|
| `--system-prompt <text>` | **Replace** the entire system prompt (use --append instead usually) |
|
||||||
|
| `--system-prompt-file <path>` | **Replace** the system prompt with file contents |
|
||||||
|
| `--bare` | Skip hooks, plugins, MCP discovery, CLAUDE.md, OAuth (fastest startup) |
|
||||||
|
| `--agents '<json>'` | Define custom subagents dynamically as JSON |
|
||||||
|
| `--mcp-config <path>` | Load MCP servers from JSON file (repeatable) |
|
||||||
|
| `--strict-mcp-config` | Only use MCP servers from `--mcp-config`, ignoring all other MCP configs |
|
||||||
|
| `--settings <file-or-json>` | Load additional settings from a JSON file or inline JSON |
|
||||||
|
| `--setting-sources <sources>` | Comma-separated sources to load: `user`, `project`, `local` |
|
||||||
|
| `--plugin-dir <paths...>` | Load plugins from directories for this session only |
|
||||||
|
| `--disable-slash-commands` | Disable all skills/slash commands |
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
| Flag | Effect |
|
||||||
|
|------|--------|
|
||||||
|
| `-d, --debug [filter]` | Enable debug logging with optional category filter (e.g., `"api,hooks"`, `"!1p,!file"`) |
|
||||||
|
| `--debug-file <path>` | Write debug logs to file (implicitly enables debug mode) |
|
||||||
|
|
||||||
|
### Agent Teams
|
||||||
|
| Flag | Effect |
|
||||||
|
|------|--------|
|
||||||
|
| `--teammate-mode <mode>` | How agent teams display: `auto`, `in-process`, or `tmux` |
|
||||||
|
| `--brief` | Enable `SendUserMessage` tool for agent-to-user communication |
|
||||||
|
|
||||||
|
### Tool Name Syntax for --allowedTools / --disallowedTools
|
||||||
|
```
|
||||||
|
Read # All file reading
|
||||||
|
Edit # File editing (existing files)
|
||||||
|
Write # File creation (new files)
|
||||||
|
Bash # All shell commands
|
||||||
|
Bash(git *) # Only git commands
|
||||||
|
Bash(git commit *) # Only git commit commands
|
||||||
|
Bash(npm run lint:*) # Pattern matching with wildcards
|
||||||
|
WebSearch # Web search capability
|
||||||
|
WebFetch # Web page fetching
|
||||||
|
mcp__<server>__<tool> # Specific MCP tool
|
||||||
|
```
|
||||||
|
|
||||||
|
## Settings & Configuration
|
||||||
|
|
||||||
|
### Settings Hierarchy (highest to lowest priority)
|
||||||
|
1. **CLI flags** — override everything
|
||||||
|
2. **Local project:** `.claude/settings.local.json` (personal, gitignored)
|
||||||
|
3. **Project:** `.claude/settings.json` (shared, git-tracked)
|
||||||
|
4. **User:** `~/.claude/settings.json` (global)
|
||||||
|
|
||||||
|
### Permissions in Settings
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": ["Bash(npm run lint:*)", "WebSearch", "Read"],
|
||||||
|
"ask": ["Write(*.ts)", "Bash(git push*)"],
|
||||||
|
"deny": ["Read(.env)", "Bash(rm -rf *)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Memory Files (CLAUDE.md) Hierarchy
|
||||||
|
1. **Global:** `~/.claude/CLAUDE.md` — applies to all projects
|
||||||
|
2. **Project:** `./CLAUDE.md` — project-specific context (git-tracked)
|
||||||
|
3. **Local:** `.claude/CLAUDE.local.md` — personal project overrides (gitignored)
|
||||||
|
|
||||||
|
Use the `#` prefix in interactive mode to quickly add to memory: `# Always use 2-space indentation`.
|
||||||
|
|
||||||
|
## Interactive Session: Slash Commands
|
||||||
|
|
||||||
|
### Session & Context
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `/help` | Show all commands (including custom and MCP commands) |
|
||||||
|
| `/compact [focus]` | Compress context to save tokens; CLAUDE.md survives compaction. E.g., `/compact focus on auth logic` |
|
||||||
|
| `/clear` | Wipe conversation history for a fresh start |
|
||||||
|
| `/context` | Visualize context usage as a colored grid with optimization tips |
|
||||||
|
| `/cost` | View token usage with per-model and cache-hit breakdowns |
|
||||||
|
| `/resume` | Switch to or resume a different session |
|
||||||
|
| `/rewind` | Revert to a previous checkpoint in conversation or code |
|
||||||
|
| `/btw <question>` | Ask a side question without adding to context cost |
|
||||||
|
| `/status` | Show version, connectivity, and session info |
|
||||||
|
| `/todos` | List tracked action items from the conversation |
|
||||||
|
| `/exit` or `Ctrl+D` | End session |
|
||||||
|
|
||||||
|
### Development & Review
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `/review` | Request code review of current changes |
|
||||||
|
| `/security-review` | Perform security analysis of current changes |
|
||||||
|
| `/plan [description]` | Enter Plan mode with auto-start for task planning |
|
||||||
|
| `/loop [interval]` | Schedule recurring tasks within the session |
|
||||||
|
| `/batch` | Auto-create worktrees for large parallel changes (5-30 worktrees) |
|
||||||
|
|
||||||
|
### Configuration & Tools
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `/model [model]` | Switch models mid-session (use arrow keys to adjust effort) |
|
||||||
|
| `/effort [level]` | Set reasoning effort: `low`, `medium`, `high`, `max`, or `auto` |
|
||||||
|
| `/init` | Create a CLAUDE.md file for project memory |
|
||||||
|
| `/memory` | Open CLAUDE.md for editing |
|
||||||
|
| `/config` | Open interactive settings configuration |
|
||||||
|
| `/permissions` | View/update tool permissions |
|
||||||
|
| `/agents` | Manage specialized subagents |
|
||||||
|
| `/mcp` | Interactive UI to manage MCP servers |
|
||||||
|
| `/add-dir` | Add additional working directories (useful for monorepos) |
|
||||||
|
| `/usage` | Show plan limits and rate limit status |
|
||||||
|
| `/voice` | Enable push-to-talk voice mode (20 languages; hold Space to record, release to send) |
|
||||||
|
| `/release-notes` | Interactive picker for version release notes |
|
||||||
|
|
||||||
|
### Custom Slash Commands
|
||||||
|
Create `.claude/commands/<name>.md` (project-shared) or `~/.claude/commands/<name>.md` (personal):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# .claude/commands/deploy.md
|
||||||
|
Run the deploy pipeline:
|
||||||
|
1. Run all tests
|
||||||
|
2. Build the Docker image
|
||||||
|
3. Push to registry
|
||||||
|
4. Update the $ARGUMENTS environment (default: staging)
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage: `/deploy production` — `$ARGUMENTS` is replaced with the user's input.
|
||||||
|
|
||||||
|
### Skills (Natural Language Invocation)
|
||||||
|
Unlike slash commands (manually invoked), skills in `.claude/skills/` are markdown guides that Claude invokes automatically via natural language when the task matches:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# .claude/skills/database-migration.md
|
||||||
|
When asked to create or modify database migrations:
|
||||||
|
1. Use Alembic for migration generation
|
||||||
|
2. Always create a rollback function
|
||||||
|
3. Test migrations against a local database copy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interactive Session: Keyboard Shortcuts
|
||||||
|
|
||||||
|
### General Controls
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `Ctrl+C` | Cancel current input or generation |
|
||||||
|
| `Ctrl+D` | Exit session |
|
||||||
|
| `Ctrl+R` | Reverse search command history |
|
||||||
|
| `Ctrl+B` | Background a running task |
|
||||||
|
| `Ctrl+V` | Paste image into conversation |
|
||||||
|
| `Ctrl+O` | Transcript mode — see Claude's thinking process |
|
||||||
|
| `Ctrl+G` or `Ctrl+X Ctrl+E` | Open prompt in external editor |
|
||||||
|
| `Esc Esc` | Rewind conversation or code state / summarize |
|
||||||
|
|
||||||
|
### Mode Toggles
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `Shift+Tab` | Cycle permission modes (Normal → Auto-Accept → Plan) |
|
||||||
|
| `Alt+P` | Switch model |
|
||||||
|
| `Alt+T` | Toggle thinking mode |
|
||||||
|
| `Alt+O` | Toggle Fast Mode |
|
||||||
|
|
||||||
|
### Multiline Input
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `\` + `Enter` | Quick newline |
|
||||||
|
| `Shift+Enter` | Newline (alternative) |
|
||||||
|
| `Ctrl+J` | Newline (alternative) |
|
||||||
|
|
||||||
|
### Input Prefixes
|
||||||
|
| Prefix | Action |
|
||||||
|
|--------|--------|
|
||||||
|
| `!` | Execute bash directly, bypassing AI (e.g., `!npm test`). Use `!` alone to toggle shell mode. |
|
||||||
|
| `@` | Reference files/directories with autocomplete (e.g., `@./src/api/`) |
|
||||||
|
| `#` | Quick add to CLAUDE.md memory (e.g., `# Use 2-space indentation`) |
|
||||||
|
| `/` | Slash commands |
|
||||||
|
|
||||||
|
### Pro Tip: "ultrathink"
|
||||||
|
Use the keyword "ultrathink" in your prompt for maximum reasoning effort on a specific turn. This triggers the deepest thinking mode regardless of the current `/effort` setting.
|
||||||
|
|
||||||
|
## PR Review Pattern
|
||||||
|
|
||||||
|
### Quick Review (Print Mode)
|
||||||
|
```
|
||||||
|
terminal(command="cd /path/to/repo && git diff main...feature-branch | claude -p 'Review this diff for bugs, security issues, and style problems. Be thorough.' --max-turns 1", timeout=60)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deep Review (Interactive + Worktree)
|
||||||
|
```
|
||||||
|
terminal(command="tmux new-session -d -s review -x 140 -y 40")
|
||||||
|
terminal(command="tmux send-keys -t review 'cd /path/to/repo && claude -w pr-review' Enter")
|
||||||
|
terminal(command="sleep 5 && tmux send-keys -t review Enter") # Trust dialog
|
||||||
|
terminal(command="sleep 2 && tmux send-keys -t review 'Review all changes vs main. Check for bugs, security issues, race conditions, and missing tests.' Enter")
|
||||||
|
terminal(command="sleep 30 && tmux capture-pane -t review -p -S -60")
|
||||||
|
```
|
||||||
|
|
||||||
|
### PR Review from Number
|
||||||
|
```
|
||||||
|
terminal(command="claude -p 'Review this PR thoroughly' --from-pr 42 --max-turns 10", workdir="/path/to/repo", timeout=120)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude Worktree with tmux
|
||||||
|
```
|
||||||
|
terminal(command="claude -w feature-x --tmux", workdir="/path/to/repo")
|
||||||
|
```
|
||||||
|
Creates an isolated git worktree at `.claude/worktrees/feature-x` AND a tmux session for it. Uses iTerm2 native panes when available; add `--tmux=classic` for traditional tmux.
|
||||||
|
|
||||||
|
## Parallel Claude Instances
|
||||||
|
|
||||||
|
Run multiple independent Claude tasks simultaneously:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Task 1: Fix backend
|
||||||
|
terminal(command="tmux new-session -d -s task1 -x 140 -y 40 && tmux send-keys -t task1 'cd ~/project && claude -p \"Fix the auth bug in src/auth.py\" --allowedTools \"Read,Edit\" --max-turns 10' Enter")
|
||||||
|
|
||||||
|
# Task 2: Write tests
|
||||||
|
terminal(command="tmux new-session -d -s task2 -x 140 -y 40 && tmux send-keys -t task2 'cd ~/project && claude -p \"Write integration tests for the API endpoints\" --allowedTools \"Read,Write,Bash\" --max-turns 15' Enter")
|
||||||
|
|
||||||
|
# Task 3: Update docs
|
||||||
|
terminal(command="tmux new-session -d -s task3 -x 140 -y 40 && tmux send-keys -t task3 'cd ~/project && claude -p \"Update README.md with the new API endpoints\" --allowedTools \"Read,Edit\" --max-turns 5' Enter")
|
||||||
|
|
||||||
|
# Monitor all
|
||||||
|
terminal(command="sleep 30 && for s in task1 task2 task3; do echo '=== '$s' ==='; tmux capture-pane -t $s -p -S -5 2>/dev/null; done")
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLAUDE.md — Project Context File
|
||||||
|
|
||||||
|
Claude Code auto-loads `CLAUDE.md` from the project root. Use it to persist project context:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Project: My API
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- FastAPI backend with SQLAlchemy ORM
|
||||||
|
- PostgreSQL database, Redis cache
|
||||||
|
- pytest for testing with 90% coverage target
|
||||||
|
|
||||||
|
## Key Commands
|
||||||
|
- `make test` — run full test suite
|
||||||
|
- `make lint` — ruff + mypy
|
||||||
|
- `make dev` — start dev server on :8000
|
||||||
|
|
||||||
|
## Code Standards
|
||||||
|
- Type hints on all public functions
|
||||||
|
- Docstrings in Google style
|
||||||
|
- 2-space indentation for YAML, 4-space for Python
|
||||||
|
- No wildcard imports
|
||||||
|
```
|
||||||
|
|
||||||
|
**Be specific.** Instead of "Write good code", use "Use 2-space indentation for JS" or "Name test files with `.test.ts` suffix." Specific instructions save correction cycles.
|
||||||
|
|
||||||
|
### Rules Directory (Modular CLAUDE.md)
|
||||||
|
For projects with many rules, use the rules directory instead of one massive CLAUDE.md:
|
||||||
|
- **Project rules:** `.claude/rules/*.md` — team-shared, git-tracked
|
||||||
|
- **User rules:** `~/.claude/rules/*.md` — personal, global
|
||||||
|
|
||||||
|
Each `.md` file in the rules directory is loaded as additional context. This is cleaner than cramming everything into a single CLAUDE.md.
|
||||||
|
|
||||||
|
### Auto-Memory
|
||||||
|
Claude automatically stores learned project context in `~/.claude/projects/<project>/memory/`.
|
||||||
|
- **Limit:** 25KB or 200 lines per project
|
||||||
|
- This is separate from CLAUDE.md — it's Claude's own notes about the project, accumulated across sessions
|
||||||
|
|
||||||
|
## Custom Subagents
|
||||||
|
|
||||||
|
Define specialized agents in `.claude/agents/` (project), `~/.claude/agents/` (personal), or via `--agents` CLI flag (session):
|
||||||
|
|
||||||
|
### Agent Location Priority
|
||||||
|
1. `.claude/agents/` — project-level, team-shared
|
||||||
|
2. `--agents` CLI flag — session-specific, dynamic
|
||||||
|
3. `~/.claude/agents/` — user-level, personal
|
||||||
|
|
||||||
|
### Creating an Agent
|
||||||
|
```markdown
|
||||||
|
# .claude/agents/security-reviewer.md
|
||||||
|
---
|
||||||
|
name: security-reviewer
|
||||||
|
description: Security-focused code review
|
||||||
|
model: opus
|
||||||
|
tools: [Read, Bash]
|
||||||
|
---
|
||||||
|
You are a senior security engineer. Review code for:
|
||||||
|
- Injection vulnerabilities (SQL, XSS, command injection)
|
||||||
|
- Authentication/authorization flaws
|
||||||
|
- Secrets in code
|
||||||
|
- Unsafe deserialization
|
||||||
|
```
|
||||||
|
|
||||||
|
Invoke via: `@security-reviewer review the auth module`
|
||||||
|
|
||||||
|
### Dynamic Agents via CLI
|
||||||
|
```
|
||||||
|
terminal(command="claude --agents '{\"reviewer\": {\"description\": \"Reviews code\", \"prompt\": \"You are a code reviewer focused on performance\"}}' -p 'Use @reviewer to check auth.py'", timeout=120)
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude can orchestrate multiple agents: "Use @db-expert to optimize queries, then @security to audit the changes."
|
||||||
|
|
||||||
|
## Hooks — Automation on Events
|
||||||
|
|
||||||
|
Configure in `.claude/settings.json` (project) or `~/.claude/settings.json` (global):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [{
|
||||||
|
"matcher": "Write(*.py)",
|
||||||
|
"hooks": [{"type": "command", "command": "ruff check --fix $CLAUDE_FILE_PATHS"}]
|
||||||
|
}],
|
||||||
|
"PreToolUse": [{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [{"type": "command", "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -q 'rm -rf'; then echo 'Blocked!' && exit 2; fi"}]
|
||||||
|
}],
|
||||||
|
"Stop": [{
|
||||||
|
"hooks": [{"type": "command", "command": "echo 'Claude finished a response' >> /tmp/claude-activity.log"}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### All 8 Hook Types
|
||||||
|
| Hook | When it fires | Common use |
|
||||||
|
|------|--------------|------------|
|
||||||
|
| `UserPromptSubmit` | Before Claude processes a user prompt | Input validation, logging |
|
||||||
|
| `PreToolUse` | Before tool execution | Security gates, block dangerous commands (exit 2 = block) |
|
||||||
|
| `PostToolUse` | After a tool finishes | Auto-format code, run linters |
|
||||||
|
| `Notification` | On permission requests or input waits | Desktop notifications, alerts |
|
||||||
|
| `Stop` | When Claude finishes a response | Completion logging, status updates |
|
||||||
|
| `SubagentStop` | When a subagent completes | Agent orchestration |
|
||||||
|
| `PreCompact` | Before context memory is cleared | Backup session transcripts |
|
||||||
|
| `SessionStart` | When a session begins | Load dev context (e.g., `git status`) |
|
||||||
|
|
||||||
|
### Hook Environment Variables
|
||||||
|
| Variable | Content |
|
||||||
|
|----------|---------|
|
||||||
|
| `CLAUDE_PROJECT_DIR` | Current project path |
|
||||||
|
| `CLAUDE_FILE_PATHS` | Files being modified |
|
||||||
|
| `CLAUDE_TOOL_INPUT` | Tool parameters as JSON |
|
||||||
|
|
||||||
|
### Security Hook Examples
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"PreToolUse": [{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [{"type": "command", "command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qE 'rm -rf|git push.*--force|:(){ :|:& };:'; then echo 'Dangerous command blocked!' && exit 2; fi"}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP Integration
|
||||||
|
|
||||||
|
Add external tool servers for databases, APIs, and services:
|
||||||
|
|
||||||
|
```
|
||||||
|
# GitHub integration
|
||||||
|
terminal(command="claude mcp add -s user github -- npx @modelcontextprotocol/server-github", timeout=30)
|
||||||
|
|
||||||
|
# PostgreSQL queries
|
||||||
|
terminal(command="claude mcp add -s local postgres -- npx @anthropic-ai/server-postgres --connection-string postgresql://localhost/mydb", timeout=30)
|
||||||
|
|
||||||
|
# Puppeteer for web testing
|
||||||
|
terminal(command="claude mcp add puppeteer -- npx @anthropic-ai/server-puppeteer", timeout=30)
|
||||||
|
```
|
||||||
|
|
||||||
|
### MCP Scopes
|
||||||
|
| Flag | Scope | Storage |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `-s user` | Global (all projects) | `~/.claude.json` |
|
||||||
|
| `-s local` | This project (personal) | `.claude/settings.local.json` (gitignored) |
|
||||||
|
| `-s project` | This project (team-shared) | `.claude/settings.json` (git-tracked) |
|
||||||
|
|
||||||
|
### MCP in Print/CI Mode
|
||||||
|
```
|
||||||
|
terminal(command="claude --bare -p 'Query database' --mcp-config mcp-servers.json --strict-mcp-config", timeout=60)
|
||||||
|
```
|
||||||
|
`--strict-mcp-config` ignores all MCP servers except those from `--mcp-config`.
|
||||||
|
|
||||||
|
Reference MCP resources in chat: `@github:issue://123`
|
||||||
|
|
||||||
|
### MCP Limits & Tuning
|
||||||
|
- **Tool descriptions:** 2KB cap per server for tool descriptions and server instructions
|
||||||
|
- **Result size:** Default capped; use `maxResultSizeChars` annotation to allow up to **500K** characters for large outputs
|
||||||
|
- **Output tokens:** `export MAX_MCP_OUTPUT_TOKENS=50000` — cap output from MCP servers to prevent context flooding
|
||||||
|
- **Transports:** `stdio` (local process), `http` (remote), `sse` (server-sent events)
|
||||||
|
|
||||||
|
## Monitoring Interactive Sessions
|
||||||
|
|
||||||
|
### Reading the TUI Status
|
||||||
|
```
|
||||||
|
# Periodic capture to check if Claude is still working or waiting for input
|
||||||
|
terminal(command="tmux capture-pane -t dev -p -S -10")
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for these indicators:
|
||||||
|
- `❯` at bottom = waiting for your input (Claude is done or asking a question)
|
||||||
|
- `●` lines = Claude is actively using tools (reading, writing, running commands)
|
||||||
|
- `⏵⏵ bypass permissions on` = status bar showing permissions mode
|
||||||
|
- `◐ medium · /effort` = current effort level in status bar
|
||||||
|
- `ctrl+o to expand` = tool output was truncated (can be expanded interactively)
|
||||||
|
|
||||||
|
### Context Window Health
|
||||||
|
Use `/context` in interactive mode to see a colored grid of context usage. Key thresholds:
|
||||||
|
- **< 70%** — Normal operation, full precision
|
||||||
|
- **70-85%** — Precision starts dropping, consider `/compact`
|
||||||
|
- **> 85%** — Hallucination risk spikes significantly, use `/compact` or `/clear`
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Effect |
|
||||||
|
|----------|--------|
|
||||||
|
| `ANTHROPIC_API_KEY` | API key for authentication (alternative to OAuth) |
|
||||||
|
| `CLAUDE_CODE_EFFORT_LEVEL` | Default effort: `low`, `medium`, `high`, `max`, or `auto` |
|
||||||
|
| `MAX_THINKING_TOKENS` | Cap thinking tokens (set to `0` to disable thinking entirely) |
|
||||||
|
| `MAX_MCP_OUTPUT_TOKENS` | Cap output from MCP servers (default varies; set e.g., `50000`) |
|
||||||
|
| `CLAUDE_CODE_NO_FLICKER=1` | Enable alt-screen rendering to eliminate terminal flicker |
|
||||||
|
| `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` | Strip credentials from sub-processes for security |
|
||||||
|
|
||||||
|
## Cost & Performance Tips
|
||||||
|
|
||||||
|
1. **Use `--max-turns`** in print mode to prevent runaway loops. Start with 5-10 for most tasks.
|
||||||
|
2. **Use `--max-budget-usd`** for cost caps. Note: minimum ~$0.05 for system prompt cache creation.
|
||||||
|
3. **Use `--effort low`** for simple tasks (faster, cheaper). `high` or `max` for complex reasoning.
|
||||||
|
4. **Use `--bare`** for CI/scripting to skip plugin/hook discovery overhead.
|
||||||
|
5. **Use `--allowedTools`** to restrict to only what's needed (e.g., `Read` only for reviews).
|
||||||
|
6. **Use `/compact`** in interactive sessions when context gets large.
|
||||||
|
7. **Pipe input** instead of having Claude read files when you just need analysis of known content.
|
||||||
|
8. **Use `--model haiku`** for simple tasks (cheaper) and `--model opus` for complex multi-step work.
|
||||||
|
9. **Use `--fallback-model haiku`** in print mode to gracefully handle model overload.
|
||||||
|
10. **Start new sessions for distinct tasks** — sessions last 5 hours; fresh context is more efficient.
|
||||||
|
11. **Use `--no-session-persistence`** in CI to avoid accumulating saved sessions on disk.
|
||||||
|
|
||||||
|
## Pitfalls & Gotchas
|
||||||
|
|
||||||
|
1. **Interactive mode REQUIRES tmux** — Claude Code is a full TUI app. Using `pty=true` alone in Hermes terminal works but tmux gives you `capture-pane` for monitoring and `send-keys` for input, which is essential for orchestration.
|
||||||
|
2. **`--dangerously-skip-permissions` dialog defaults to "No, exit"** — you must send Down then Enter to accept. Print mode (`-p`) skips this entirely.
|
||||||
|
3. **`--max-budget-usd` minimum is ~$0.05** — system prompt cache creation alone costs this much. Setting lower will error immediately.
|
||||||
|
4. **`--max-turns` is print-mode only** — ignored in interactive sessions.
|
||||||
|
5. **Claude may use `python` instead of `python3`** — on systems without a `python` symlink, Claude's bash commands will fail on first try but it self-corrects.
|
||||||
|
6. **Session resumption requires same directory** — `--continue` finds the most recent session for the current working directory.
|
||||||
|
7. **`--json-schema` needs enough `--max-turns`** — Claude must read files before producing structured output, which takes multiple turns.
|
||||||
|
8. **Trust dialog only appears once per directory** — first-time only, then cached.
|
||||||
|
9. **Background tmux sessions persist** — always clean up with `tmux kill-session -t <name>` when done.
|
||||||
|
10. **Slash commands (like `/commit`) only work in interactive mode** — in `-p` mode, describe the task in natural language instead.
|
||||||
|
11. **`--bare` skips OAuth** — requires `ANTHROPIC_API_KEY` env var or an `apiKeyHelper` in settings.
|
||||||
|
12. **Context degradation is real** — AI output quality measurably degrades above 70% context window usage. Monitor with `/context` and proactively `/compact`.
|
||||||
|
|
||||||
|
## Rules for Hermes Agents
|
||||||
|
|
||||||
|
1. **Prefer print mode (`-p`) for single tasks** — cleaner, no dialog handling, structured output
|
||||||
|
2. **Use tmux for multi-turn interactive work** — the only reliable way to orchestrate the TUI
|
||||||
|
3. **Always set `workdir`** — keep Claude focused on the right project directory
|
||||||
|
4. **Set `--max-turns` in print mode** — prevents infinite loops and runaway costs
|
||||||
|
5. **Monitor tmux sessions** — use `tmux capture-pane -t <session> -p -S -50` to check progress
|
||||||
|
6. **Look for the `❯` prompt** — indicates Claude is waiting for input (done or asking a question)
|
||||||
|
7. **Clean up tmux sessions** — kill them when done to avoid resource leaks
|
||||||
|
8. **Report results to user** — after completion, summarize what Claude did and what changed
|
||||||
|
9. **Don't kill slow sessions** — Claude may be doing multi-step work; check progress instead
|
||||||
|
10. **Use `--allowedTools`** — restrict capabilities to what the task actually needs
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
name: codex
|
||||||
|
description: "Delegate coding to OpenAI Codex CLI (features, PRs)."
|
||||||
|
version: 1.0.0
|
||||||
|
author: Hermes Agent
|
||||||
|
license: MIT
|
||||||
|
platforms: [linux, macos, windows]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [Coding-Agent, Codex, OpenAI, Code-Review, Refactoring]
|
||||||
|
related_skills: [claude-code, hermes-agent]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Codex CLI
|
||||||
|
|
||||||
|
Delegate coding tasks to [Codex](https://github.com/openai/codex) via the Hermes terminal. Codex is OpenAI's autonomous coding agent CLI.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
- Building features
|
||||||
|
- Refactoring
|
||||||
|
- PR reviews
|
||||||
|
- Batch issue fixing
|
||||||
|
|
||||||
|
Requires the codex CLI and a git repository.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Codex installed: `npm install -g @openai/codex`
|
||||||
|
- OpenAI auth configured: either `OPENAI_API_KEY` or Codex OAuth credentials
|
||||||
|
from the Codex CLI login flow
|
||||||
|
- **Must run inside a git repository** — Codex refuses to run outside one
|
||||||
|
- Use `pty=true` in terminal calls — Codex is an interactive terminal app
|
||||||
|
|
||||||
|
For Hermes itself, `model.provider: openai-codex` uses Hermes-managed Codex
|
||||||
|
OAuth from `~/.hermes/auth.json` after `hermes auth add openai-codex`. For the
|
||||||
|
standalone Codex CLI, a valid CLI OAuth session may live under
|
||||||
|
`~/.codex/auth.json`; do not treat a missing `OPENAI_API_KEY` alone as proof
|
||||||
|
that Codex auth is missing.
|
||||||
|
|
||||||
|
## One-Shot Tasks
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="codex exec 'Add dark mode toggle to settings'", workdir="~/project", pty=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
For scratch work (Codex needs a git repo):
|
||||||
|
```
|
||||||
|
terminal(command="cd $(mktemp -d) && git init && codex exec 'Build a snake game in Python'", pty=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Background Mode (Long Tasks)
|
||||||
|
|
||||||
|
```
|
||||||
|
# Start in background with PTY
|
||||||
|
terminal(command="codex exec --full-auto 'Refactor the auth module'", workdir="~/project", background=true, pty=true)
|
||||||
|
# Returns session_id
|
||||||
|
|
||||||
|
# Monitor progress
|
||||||
|
process(action="poll", session_id="<id>")
|
||||||
|
process(action="log", session_id="<id>")
|
||||||
|
|
||||||
|
# Send input if Codex asks a question
|
||||||
|
process(action="submit", session_id="<id>", data="yes")
|
||||||
|
|
||||||
|
# Kill if needed
|
||||||
|
process(action="kill", session_id="<id>")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Flags
|
||||||
|
|
||||||
|
| Flag | Effect |
|
||||||
|
|------|--------|
|
||||||
|
| `exec "prompt"` | One-shot execution, exits when done |
|
||||||
|
| `--full-auto` | Sandboxed but auto-approves file changes in workspace |
|
||||||
|
| `--yolo` | No sandbox, no approvals (fastest, most dangerous) |
|
||||||
|
|
||||||
|
## PR Reviews
|
||||||
|
|
||||||
|
Clone to a temp directory for safe review:
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="REVIEW=$(mktemp -d) && git clone https://github.com/user/repo.git $REVIEW && cd $REVIEW && gh pr checkout 42 && codex review --base origin/main", pty=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Issue Fixing with Worktrees
|
||||||
|
|
||||||
|
```
|
||||||
|
# Create worktrees
|
||||||
|
terminal(command="git worktree add -b fix/issue-78 /tmp/issue-78 main", workdir="~/project")
|
||||||
|
terminal(command="git worktree add -b fix/issue-99 /tmp/issue-99 main", workdir="~/project")
|
||||||
|
|
||||||
|
# Launch Codex in each
|
||||||
|
terminal(command="codex --yolo exec 'Fix issue #78: <description>. Commit when done.'", workdir="/tmp/issue-78", background=true, pty=true)
|
||||||
|
terminal(command="codex --yolo exec 'Fix issue #99: <description>. Commit when done.'", workdir="/tmp/issue-99", background=true, pty=true)
|
||||||
|
|
||||||
|
# Monitor
|
||||||
|
process(action="list")
|
||||||
|
|
||||||
|
# After completion, push and create PRs
|
||||||
|
terminal(command="cd /tmp/issue-78 && git push -u origin fix/issue-78")
|
||||||
|
terminal(command="gh pr create --repo user/repo --head fix/issue-78 --title 'fix: ...' --body '...'")
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
terminal(command="git worktree remove /tmp/issue-78", workdir="~/project")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Batch PR Reviews
|
||||||
|
|
||||||
|
```
|
||||||
|
# Fetch all PR refs
|
||||||
|
terminal(command="git fetch origin '+refs/pull/*/head:refs/remotes/origin/pr/*'", workdir="~/project")
|
||||||
|
|
||||||
|
# Review multiple PRs in parallel
|
||||||
|
terminal(command="codex exec 'Review PR #86. git diff origin/main...origin/pr/86'", workdir="~/project", background=true, pty=true)
|
||||||
|
terminal(command="codex exec 'Review PR #87. git diff origin/main...origin/pr/87'", workdir="~/project", background=true, pty=true)
|
||||||
|
|
||||||
|
# Post results
|
||||||
|
terminal(command="gh pr comment 86 --body '<review>'", workdir="~/project")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **Always use `pty=true`** — Codex is an interactive terminal app and hangs without a PTY
|
||||||
|
2. **Git repo required** — Codex won't run outside a git directory. Use `mktemp -d && git init` for scratch
|
||||||
|
3. **Use `exec` for one-shots** — `codex exec "prompt"` runs and exits cleanly
|
||||||
|
4. **`--full-auto` for building** — auto-approves changes within the sandbox
|
||||||
|
5. **Background for long tasks** — use `background=true` and monitor with `process` tool
|
||||||
|
6. **Don't interfere** — monitor with `poll`/`log`, be patient with long-running tasks
|
||||||
|
7. **Parallel is fine** — run multiple Codex processes at once for batch work
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
---
|
||||||
|
name: opencode
|
||||||
|
description: "Delegate coding to OpenCode CLI (features, PR review)."
|
||||||
|
version: 1.2.0
|
||||||
|
author: Hermes Agent
|
||||||
|
license: MIT
|
||||||
|
platforms: [linux, macos, windows]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [Coding-Agent, OpenCode, Autonomous, Refactoring, Code-Review]
|
||||||
|
related_skills: [claude-code, codex, hermes-agent]
|
||||||
|
---
|
||||||
|
|
||||||
|
# OpenCode CLI
|
||||||
|
|
||||||
|
Use [OpenCode](https://opencode.ai) as an autonomous coding worker orchestrated by Hermes terminal/process tools. OpenCode is a provider-agnostic, open-source AI coding agent with a TUI and CLI.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- User explicitly asks to use OpenCode
|
||||||
|
- You want an external coding agent to implement/refactor/review code
|
||||||
|
- You need long-running coding sessions with progress checks
|
||||||
|
- You want parallel task execution in isolated workdirs/worktrees
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- OpenCode installed: `npm i -g opencode-ai@latest` or `brew install anomalyco/tap/opencode`
|
||||||
|
- Auth configured: `opencode auth login` or set provider env vars (OPENROUTER_API_KEY, etc.)
|
||||||
|
- Verify: `opencode auth list` should show at least one provider
|
||||||
|
- Git repository for code tasks (recommended)
|
||||||
|
- `pty=true` for interactive TUI sessions
|
||||||
|
|
||||||
|
## Binary Resolution (Important)
|
||||||
|
|
||||||
|
Shell environments may resolve different OpenCode binaries. If behavior differs between your terminal and Hermes, check:
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="which -a opencode")
|
||||||
|
terminal(command="opencode --version")
|
||||||
|
```
|
||||||
|
|
||||||
|
If needed, pin an explicit binary path:
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="$HOME/.opencode/bin/opencode run '...'", workdir="~/project", pty=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
## One-Shot Tasks
|
||||||
|
|
||||||
|
Use `opencode run` for bounded, non-interactive tasks:
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="opencode run 'Add retry logic to API calls and update tests'", workdir="~/project")
|
||||||
|
```
|
||||||
|
|
||||||
|
Attach context files with `-f`:
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="opencode run 'Review this config for security issues' -f config.yaml -f .env.example", workdir="~/project")
|
||||||
|
```
|
||||||
|
|
||||||
|
Show model thinking with `--thinking`:
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="opencode run 'Debug why tests fail in CI' --thinking", workdir="~/project")
|
||||||
|
```
|
||||||
|
|
||||||
|
Force a specific model:
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="opencode run 'Refactor auth module' --model openrouter/anthropic/claude-sonnet-4", workdir="~/project")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interactive Sessions (Background)
|
||||||
|
|
||||||
|
For iterative work requiring multiple exchanges, start the TUI in background:
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="opencode", workdir="~/project", background=true, pty=true)
|
||||||
|
# Returns session_id
|
||||||
|
|
||||||
|
# Send a prompt
|
||||||
|
process(action="submit", session_id="<id>", data="Implement OAuth refresh flow and add tests")
|
||||||
|
|
||||||
|
# Monitor progress
|
||||||
|
process(action="poll", session_id="<id>")
|
||||||
|
process(action="log", session_id="<id>")
|
||||||
|
|
||||||
|
# Send follow-up input
|
||||||
|
process(action="submit", session_id="<id>", data="Now add error handling for token expiry")
|
||||||
|
|
||||||
|
# Exit cleanly — Ctrl+C
|
||||||
|
process(action="write", session_id="<id>", data="\x03")
|
||||||
|
# Or just kill the process
|
||||||
|
process(action="kill", session_id="<id>")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** Do NOT use `/exit` — it is not a valid OpenCode command and will open an agent selector dialog instead. Use Ctrl+C (`\x03`) or `process(action="kill")` to exit.
|
||||||
|
|
||||||
|
### TUI Keybindings
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `Enter` | Submit message (press twice if needed) |
|
||||||
|
| `Tab` | Switch between agents (build/plan) |
|
||||||
|
| `Ctrl+P` | Open command palette |
|
||||||
|
| `Ctrl+X L` | Switch session |
|
||||||
|
| `Ctrl+X M` | Switch model |
|
||||||
|
| `Ctrl+X N` | New session |
|
||||||
|
| `Ctrl+X E` | Open editor |
|
||||||
|
| `Ctrl+C` | Exit OpenCode |
|
||||||
|
|
||||||
|
### Resuming Sessions
|
||||||
|
|
||||||
|
After exiting, OpenCode prints a session ID. Resume with:
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="opencode -c", workdir="~/project", background=true, pty=true) # Continue last session
|
||||||
|
terminal(command="opencode -s ses_abc123", workdir="~/project", background=true, pty=true) # Specific session
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Flags
|
||||||
|
|
||||||
|
| Flag | Use |
|
||||||
|
|------|-----|
|
||||||
|
| `run 'prompt'` | One-shot execution and exit |
|
||||||
|
| `--continue` / `-c` | Continue the last OpenCode session |
|
||||||
|
| `--session <id>` / `-s` | Continue a specific session |
|
||||||
|
| `--agent <name>` | Choose OpenCode agent (build or plan) |
|
||||||
|
| `--model provider/model` | Force specific model |
|
||||||
|
| `--format json` | Machine-readable output/events |
|
||||||
|
| `--file <path>` / `-f` | Attach file(s) to the message |
|
||||||
|
| `--thinking` | Show model thinking blocks |
|
||||||
|
| `--variant <level>` | Reasoning effort (high, max, minimal) |
|
||||||
|
| `--title <name>` | Name the session |
|
||||||
|
| `--attach <url>` | Connect to a running opencode server |
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. Verify tool readiness:
|
||||||
|
- `terminal(command="opencode --version")`
|
||||||
|
- `terminal(command="opencode auth list")`
|
||||||
|
2. For bounded tasks, use `opencode run '...'` (no pty needed).
|
||||||
|
3. For iterative tasks, start `opencode` with `background=true, pty=true`.
|
||||||
|
4. Monitor long tasks with `process(action="poll"|"log")`.
|
||||||
|
5. If OpenCode asks for input, respond via `process(action="submit", ...)`.
|
||||||
|
6. Exit with `process(action="write", data="\x03")` or `process(action="kill")`.
|
||||||
|
7. Summarize file changes, test results, and next steps back to user.
|
||||||
|
|
||||||
|
## PR Review Workflow
|
||||||
|
|
||||||
|
OpenCode has a built-in PR command:
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="opencode pr 42", workdir="~/project", pty=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
Or review in a temporary clone for isolation:
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="REVIEW=$(mktemp -d) && git clone https://github.com/user/repo.git $REVIEW && cd $REVIEW && opencode run 'Review this PR vs main. Report bugs, security risks, test gaps, and style issues.' -f $(git diff origin/main --name-only | head -20 | tr '\n' ' ')", pty=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Work Pattern
|
||||||
|
|
||||||
|
Use separate workdirs/worktrees to avoid collisions:
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="opencode run 'Fix issue #101 and commit'", workdir="/tmp/issue-101", background=true, pty=true)
|
||||||
|
terminal(command="opencode run 'Add parser regression tests and commit'", workdir="/tmp/issue-102", background=true, pty=true)
|
||||||
|
process(action="list")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session & Cost Management
|
||||||
|
|
||||||
|
List past sessions:
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="opencode session list")
|
||||||
|
```
|
||||||
|
|
||||||
|
Check token usage and costs:
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="opencode stats")
|
||||||
|
terminal(command="opencode stats --days 7 --models anthropic/claude-sonnet-4")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
|
||||||
|
- Interactive `opencode` (TUI) sessions require `pty=true`. The `opencode run` command does NOT need pty.
|
||||||
|
- `/exit` is NOT a valid command — it opens an agent selector. Use Ctrl+C to exit the TUI.
|
||||||
|
- PATH mismatch can select the wrong OpenCode binary/model config.
|
||||||
|
- If OpenCode appears stuck, inspect logs before killing:
|
||||||
|
- `process(action="log", session_id="<id>")`
|
||||||
|
- Avoid sharing one working directory across parallel OpenCode sessions.
|
||||||
|
- Enter may need to be pressed twice to submit in the TUI (once to finalize text, once to send).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Smoke test:
|
||||||
|
|
||||||
|
```
|
||||||
|
terminal(command="opencode run 'Respond with exactly: OPENCODE_SMOKE_OK'")
|
||||||
|
```
|
||||||
|
|
||||||
|
Success criteria:
|
||||||
|
- Output includes `OPENCODE_SMOKE_OK`
|
||||||
|
- Command exits without provider/model errors
|
||||||
|
- For code tasks: expected files changed and tests pass
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. Prefer `opencode run` for one-shot automation — it's simpler and doesn't need pty.
|
||||||
|
2. Use interactive background mode only when iteration is needed.
|
||||||
|
3. Always scope OpenCode sessions to a single repo/workdir.
|
||||||
|
4. For long tasks, provide progress updates from `process` logs.
|
||||||
|
5. Report concrete outcomes (files changed, tests, remaining risks).
|
||||||
|
6. Exit interactive sessions with Ctrl+C or kill, never `/exit`.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,344 @@
|
|||||||
|
# Native MCP Client
|
||||||
|
|
||||||
|
Hermes Agent has a built-in MCP client that connects to MCP servers at startup, discovers their tools, and makes them available as first-class tools the agent can call directly. No bridge CLI needed -- tools from MCP servers appear alongside built-in tools like `terminal`, `read_file`, etc.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use this whenever you want to:
|
||||||
|
- Connect to MCP servers and use their tools from within Hermes Agent
|
||||||
|
- Add external capabilities (filesystem access, GitHub, databases, APIs) via MCP
|
||||||
|
- Run local stdio-based MCP servers (npx, uvx, or any command)
|
||||||
|
- Connect to remote HTTP/StreamableHTTP MCP servers
|
||||||
|
- Have MCP tools auto-discovered and available in every conversation
|
||||||
|
|
||||||
|
For ad-hoc, one-off MCP tool calls from the terminal without configuring anything, see the `mcporter` skill instead.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **mcp Python package** -- optional dependency; install with `pip install mcp`. If not installed, MCP support is silently disabled.
|
||||||
|
- **Node.js** -- required for `npx`-based MCP servers (most community servers)
|
||||||
|
- **uv** -- required for `uvx`-based MCP servers (Python-based servers)
|
||||||
|
|
||||||
|
Install the MCP SDK:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install mcp
|
||||||
|
# or, if using uv:
|
||||||
|
uv pip install mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Add MCP servers to `~/.hermes/config.yaml` under the `mcp_servers` key:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
time:
|
||||||
|
command: "uvx"
|
||||||
|
args: ["mcp-server-time"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart Hermes Agent. On startup it will:
|
||||||
|
1. Connect to the server
|
||||||
|
2. Discover available tools
|
||||||
|
3. Register them with the prefix `mcp_time_*`
|
||||||
|
4. Inject them into all platform toolsets
|
||||||
|
|
||||||
|
You can then use the tools naturally -- just ask the agent to get the current time.
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
Each entry under `mcp_servers` is a server name mapped to its config. There are two transport types: **stdio** (command-based) and **HTTP** (url-based).
|
||||||
|
|
||||||
|
### Stdio Transport (command + args)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
server_name:
|
||||||
|
command: "npx" # (required) executable to run
|
||||||
|
args: ["-y", "pkg-name"] # (optional) command arguments, default: []
|
||||||
|
env: # (optional) environment variables for the subprocess
|
||||||
|
SOME_API_KEY: "value"
|
||||||
|
timeout: 120 # (optional) per-tool-call timeout in seconds, default: 120
|
||||||
|
connect_timeout: 60 # (optional) initial connection timeout in seconds, default: 60
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTP Transport (url)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
server_name:
|
||||||
|
url: "https://my-server.example.com/mcp" # (required) server URL
|
||||||
|
headers: # (optional) HTTP headers
|
||||||
|
Authorization: "Bearer sk-..."
|
||||||
|
timeout: 180 # (optional) per-tool-call timeout in seconds, default: 120
|
||||||
|
connect_timeout: 60 # (optional) initial connection timeout in seconds, default: 60
|
||||||
|
```
|
||||||
|
|
||||||
|
### All Config Options
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|-------------------|--------|---------|---------------------------------------------------|
|
||||||
|
| `command` | string | -- | Executable to run (stdio transport, required) |
|
||||||
|
| `args` | list | `[]` | Arguments passed to the command |
|
||||||
|
| `env` | dict | `{}` | Extra environment variables for the subprocess |
|
||||||
|
| `url` | string | -- | Server URL (HTTP transport, required) |
|
||||||
|
| `headers` | dict | `{}` | HTTP headers sent with every request |
|
||||||
|
| `timeout` | int | `120` | Per-tool-call timeout in seconds |
|
||||||
|
| `connect_timeout` | int | `60` | Timeout for initial connection and discovery |
|
||||||
|
|
||||||
|
Note: A server config must have either `command` (stdio) or `url` (HTTP), not both.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Startup Discovery
|
||||||
|
|
||||||
|
When Hermes Agent starts, `discover_mcp_tools()` is called during tool initialization:
|
||||||
|
|
||||||
|
1. Reads `mcp_servers` from `~/.hermes/config.yaml`
|
||||||
|
2. For each server, spawns a connection in a dedicated background event loop
|
||||||
|
3. Initializes the MCP session and calls `list_tools()` to discover available tools
|
||||||
|
4. Registers each tool in the Hermes tool registry
|
||||||
|
|
||||||
|
### Tool Naming Convention
|
||||||
|
|
||||||
|
MCP tools are registered with the naming pattern:
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp_{server_name}_{tool_name}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hyphens and dots in names are replaced with underscores for LLM API compatibility.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Server `filesystem`, tool `read_file` → `mcp_filesystem_read_file`
|
||||||
|
- Server `github`, tool `list-issues` → `mcp_github_list_issues`
|
||||||
|
- Server `my-api`, tool `fetch.data` → `mcp_my_api_fetch_data`
|
||||||
|
|
||||||
|
### Auto-Injection
|
||||||
|
|
||||||
|
After discovery, MCP tools are automatically injected into all `hermes-*` platform toolsets (CLI, Discord, Telegram, etc.). This means MCP tools are available in every conversation without any additional configuration.
|
||||||
|
|
||||||
|
### Connection Lifecycle
|
||||||
|
|
||||||
|
- Each server runs as a long-lived asyncio Task in a background daemon thread
|
||||||
|
- Connections persist for the lifetime of the agent process
|
||||||
|
- If a connection drops, automatic reconnection with exponential backoff kicks in (up to 5 retries, max 60s backoff)
|
||||||
|
- On agent shutdown, all connections are gracefully closed
|
||||||
|
|
||||||
|
### Idempotency
|
||||||
|
|
||||||
|
`discover_mcp_tools()` is idempotent -- calling it multiple times only connects to servers that aren't already connected. Failed servers are retried on subsequent calls.
|
||||||
|
|
||||||
|
## Transport Types
|
||||||
|
|
||||||
|
### Stdio Transport
|
||||||
|
|
||||||
|
The most common transport. Hermes launches the MCP server as a subprocess and communicates over stdin/stdout.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
filesystem:
|
||||||
|
command: "npx"
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
|
||||||
|
```
|
||||||
|
|
||||||
|
The subprocess inherits a **filtered** environment (see Security section below) plus any variables you specify in `env`.
|
||||||
|
|
||||||
|
### HTTP / StreamableHTTP Transport
|
||||||
|
|
||||||
|
For remote or shared MCP servers. Requires the `mcp` package to include HTTP client support (`mcp.client.streamable_http`).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
remote_api:
|
||||||
|
url: "https://mcp.example.com/mcp"
|
||||||
|
headers:
|
||||||
|
Authorization: "Bearer sk-..."
|
||||||
|
```
|
||||||
|
|
||||||
|
If HTTP support is not available in your installed `mcp` version, the server will fail with an ImportError and other servers will continue normally.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Environment Variable Filtering
|
||||||
|
|
||||||
|
For stdio servers, Hermes does NOT pass your full shell environment to MCP subprocesses. Only safe baseline variables are inherited:
|
||||||
|
|
||||||
|
- `PATH`, `HOME`, `USER`, `LANG`, `LC_ALL`, `TERM`, `SHELL`, `TMPDIR`
|
||||||
|
- Any `XDG_*` variables
|
||||||
|
|
||||||
|
All other environment variables (API keys, tokens, secrets) are excluded unless you explicitly add them via the `env` config key. This prevents accidental credential leakage to untrusted MCP servers.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
github:
|
||||||
|
command: "npx"
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-github"]
|
||||||
|
env:
|
||||||
|
# Only this token is passed to the subprocess
|
||||||
|
GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### Credential Stripping in Error Messages
|
||||||
|
|
||||||
|
If an MCP tool call fails, any credential-like patterns in the error message are automatically redacted before being shown to the LLM. This covers:
|
||||||
|
|
||||||
|
- GitHub PATs (`ghp_...`)
|
||||||
|
- OpenAI-style keys (`sk-...`)
|
||||||
|
- Bearer tokens
|
||||||
|
- Generic `token=`, `key=`, `API_KEY=`, `password=`, `secret=` patterns
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "MCP SDK not available -- skipping MCP tool discovery"
|
||||||
|
|
||||||
|
The `mcp` Python package is not installed. Install it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### "No MCP servers configured"
|
||||||
|
|
||||||
|
No `mcp_servers` key in `~/.hermes/config.yaml`, or it's empty. Add at least one server.
|
||||||
|
|
||||||
|
### "Failed to connect to MCP server 'X'"
|
||||||
|
|
||||||
|
Common causes:
|
||||||
|
- **Command not found**: The `command` binary isn't on PATH. Ensure `npx`, `uvx`, or the relevant command is installed.
|
||||||
|
- **Package not found**: For npx servers, the npm package may not exist or may need `-y` in args to auto-install.
|
||||||
|
- **Timeout**: The server took too long to start. Increase `connect_timeout`.
|
||||||
|
- **Port conflict**: For HTTP servers, the URL may be unreachable.
|
||||||
|
|
||||||
|
### "MCP server 'X' requires HTTP transport but mcp.client.streamable_http is not available"
|
||||||
|
|
||||||
|
Your `mcp` package version doesn't include HTTP client support. Upgrade:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install --upgrade mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tools not appearing
|
||||||
|
|
||||||
|
- Check that the server is listed under `mcp_servers` (not `mcp` or `servers`)
|
||||||
|
- Ensure the YAML indentation is correct
|
||||||
|
- Look at Hermes Agent startup logs for connection messages
|
||||||
|
- Tool names are prefixed with `mcp_{server}_{tool}` -- look for that pattern
|
||||||
|
|
||||||
|
### Connection keeps dropping
|
||||||
|
|
||||||
|
The client retries up to 5 times with exponential backoff (1s, 2s, 4s, 8s, 16s, capped at 60s). If the server is fundamentally unreachable, it gives up after 5 attempts. Check the server process and network connectivity.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Time Server (uvx)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
time:
|
||||||
|
command: "uvx"
|
||||||
|
args: ["mcp-server-time"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Registers tools like `mcp_time_get_current_time`.
|
||||||
|
|
||||||
|
### Filesystem Server (npx)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
filesystem:
|
||||||
|
command: "npx"
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/documents"]
|
||||||
|
timeout: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
Registers tools like `mcp_filesystem_read_file`, `mcp_filesystem_write_file`, `mcp_filesystem_list_directory`.
|
||||||
|
|
||||||
|
### GitHub Server with Authentication
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
github:
|
||||||
|
command: "npx"
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-github"]
|
||||||
|
env:
|
||||||
|
GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_xxxxxxxxxxxxxxxxxxxx"
|
||||||
|
timeout: 60
|
||||||
|
```
|
||||||
|
|
||||||
|
Registers tools like `mcp_github_list_issues`, `mcp_github_create_pull_request`, etc.
|
||||||
|
|
||||||
|
### Remote HTTP Server
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
company_api:
|
||||||
|
url: "https://mcp.mycompany.com/v1/mcp"
|
||||||
|
headers:
|
||||||
|
Authorization: "Bearer sk-xxxxxxxxxxxxxxxxxxxx"
|
||||||
|
X-Team-Id: "engineering"
|
||||||
|
timeout: 180
|
||||||
|
connect_timeout: 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Servers
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
time:
|
||||||
|
command: "uvx"
|
||||||
|
args: ["mcp-server-time"]
|
||||||
|
|
||||||
|
filesystem:
|
||||||
|
command: "npx"
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||||
|
|
||||||
|
github:
|
||||||
|
command: "npx"
|
||||||
|
args: ["-y", "@modelcontextprotocol/server-github"]
|
||||||
|
env:
|
||||||
|
GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_xxxxxxxxxxxxxxxxxxxx"
|
||||||
|
|
||||||
|
company_api:
|
||||||
|
url: "https://mcp.internal.company.com/mcp"
|
||||||
|
headers:
|
||||||
|
Authorization: "Bearer sk-xxxxxxxxxxxxxxxxxxxx"
|
||||||
|
timeout: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
All tools from all servers are registered and available simultaneously. Each server's tools are prefixed with its name to avoid collisions.
|
||||||
|
|
||||||
|
## Sampling (Server-Initiated LLM Requests)
|
||||||
|
|
||||||
|
Hermes supports MCP's `sampling/createMessage` capability — MCP servers can request LLM completions through the agent during tool execution. This enables agent-in-the-loop workflows (data analysis, content generation, decision-making).
|
||||||
|
|
||||||
|
Sampling is **enabled by default**. Configure per server:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
my_server:
|
||||||
|
command: "npx"
|
||||||
|
args: ["-y", "my-mcp-server"]
|
||||||
|
sampling:
|
||||||
|
enabled: true # default: true
|
||||||
|
model: "gemini-3-flash" # model override (optional)
|
||||||
|
max_tokens_cap: 4096 # max tokens per request
|
||||||
|
timeout: 30 # LLM call timeout (seconds)
|
||||||
|
max_rpm: 10 # max requests per minute
|
||||||
|
allowed_models: [] # model whitelist (empty = all)
|
||||||
|
max_tool_rounds: 5 # tool loop limit (0 = disable)
|
||||||
|
log_level: "info" # audit verbosity
|
||||||
|
```
|
||||||
|
|
||||||
|
Servers can also include `tools` in sampling requests for multi-turn tool-augmented workflows. The `max_tool_rounds` config prevents infinite tool loops. Per-server audit metrics (requests, errors, tokens, tool use count) are tracked via `get_mcp_status()`.
|
||||||
|
|
||||||
|
Disable sampling for untrusted servers with `sampling: { enabled: false }`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- MCP tools are called synchronously from the agent's perspective but run asynchronously on a dedicated background event loop
|
||||||
|
- Tool results are returned as JSON with either `{"result": "..."}` or `{"error": "..."}`
|
||||||
|
- The native MCP client is independent of `mcporter` -- you can use both simultaneously
|
||||||
|
- Server connections are persistent and shared across all conversations in the same agent process
|
||||||
|
- Adding or removing servers requires restarting the agent (no hot-reload currently)
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
---
|
||||||
|
name: hermes-agent-skill-authoring
|
||||||
|
description: "Author in-repo SKILL.md: frontmatter, validator, structure."
|
||||||
|
version: 1.0.0
|
||||||
|
author: Hermes Agent
|
||||||
|
license: MIT
|
||||||
|
platforms: [linux, macos, windows]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [skills, authoring, hermes-agent, conventions, skill-md]
|
||||||
|
related_skills: [plan, requesting-code-review]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Authoring Hermes-Agent Skills (in-repo)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
There are two places a SKILL.md can live:
|
||||||
|
|
||||||
|
1. **User-local:** `~/.hermes/skills/<maybe-category>/<name>/SKILL.md` — personal, not shared. Created via `skill_manage(action='create')`.
|
||||||
|
2. **In-repo (this skill is about this case):** `/home/bb/hermes-agent/skills/<category>/<name>/SKILL.md` — committed, shipped with the package. Use `write_file` + `git add`. `skill_manage(action='create')` does NOT target this tree.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- User asks you to add a skill "in this branch / repo / commit"
|
||||||
|
- You're committing a reusable workflow that should ship with hermes-agent
|
||||||
|
- You're editing an existing skill under `/home/bb/hermes-agent/skills/` (use `patch` for small edits, `write_file` for rewrites; `skill_manage` still works for patch on in-repo skills, but not for `create`)
|
||||||
|
|
||||||
|
## Required Frontmatter
|
||||||
|
|
||||||
|
Source of truth: `tools/skill_manager_tool.py::_validate_frontmatter`. Hard requirements:
|
||||||
|
|
||||||
|
- Starts with `---` as the first bytes (no leading blank line).
|
||||||
|
- Closes with `\n---\n` before the body.
|
||||||
|
- Parses as a YAML mapping.
|
||||||
|
- `name` field present.
|
||||||
|
- `description` field present, ≤ **1024 chars** (`MAX_DESCRIPTION_LENGTH`).
|
||||||
|
- Non-empty body after the closing `---`.
|
||||||
|
|
||||||
|
Peer-matched shape used by every skill under `skills/software-development/`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: my-skill-name # lowercase, hyphens, ≤64 chars (MAX_NAME_LENGTH)
|
||||||
|
description: Use when <trigger>. <one-line behavior>.
|
||||||
|
version: 1.0.0
|
||||||
|
author: Hermes Agent
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [short, descriptive, tags]
|
||||||
|
related_skills: [other-skill, another-skill]
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
`version` / `author` / `license` / `metadata` are NOT enforced by the validator, but every peer has them — omit and your skill sticks out.
|
||||||
|
|
||||||
|
## Size Limits
|
||||||
|
|
||||||
|
- Description: ≤ 1024 chars (enforced).
|
||||||
|
- Full SKILL.md: ≤ 100,000 chars (enforced as `MAX_SKILL_CONTENT_CHARS`, ~36k tokens).
|
||||||
|
- Peer skills in `software-development/` sit at **8-14k chars**. Aim for that range. If you're pushing past 20k, split into `references/*.md` and reference them from SKILL.md.
|
||||||
|
|
||||||
|
## Peer-Matched Structure
|
||||||
|
|
||||||
|
Every in-repo skill follows roughly:
|
||||||
|
|
||||||
|
```
|
||||||
|
# <Title>
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
One or two paragraphs: what and why.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
- Bulleted triggers
|
||||||
|
- "Don't use for:" counter-triggers
|
||||||
|
|
||||||
|
## <Topic sections specific to the skill>
|
||||||
|
- Quick-reference tables are common
|
||||||
|
- Code blocks with exact commands
|
||||||
|
- Hermes-specific recipes (tests via scripts/run_tests.sh, ui-tui paths, etc.)
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
Numbered list of mistakes and their fixes.
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
- [ ] Checkbox list of post-action verifications
|
||||||
|
|
||||||
|
## One-Shot Recipes (optional)
|
||||||
|
Named scenarios → concrete command sequences.
|
||||||
|
```
|
||||||
|
|
||||||
|
Not every section is mandatory, but `Overview` + `When to Use` + actionable body + pitfalls are the minimum for the skill to feel like a peer.
|
||||||
|
|
||||||
|
## Directory Placement
|
||||||
|
|
||||||
|
```
|
||||||
|
skills/<category>/<skill-name>/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Categories currently in repo (confirm with `ls skills/`): `autonomous-ai-agents`, `creative`, `data-science`, `devops`, `dogfood`, `email`, `gaming`, `github`, `leisure`, `mcp`, `media`, `mlops/*`, `note-taking`, `productivity`, `red-teaming`, `research`, `smart-home`, `social-media`, `software-development`.
|
||||||
|
|
||||||
|
Pick the closest existing category. Don't invent new top-level categories casually.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Survey peers** in the target category:
|
||||||
|
```
|
||||||
|
ls skills/<category>/
|
||||||
|
```
|
||||||
|
Read 2-3 peer SKILL.md files to match tone and structure.
|
||||||
|
2. **Check validator constraints** in `tools/skill_manager_tool.py` if unsure.
|
||||||
|
3. **Draft** with `write_file` to `skills/<category>/<name>/SKILL.md`.
|
||||||
|
4. **Validate locally**:
|
||||||
|
```python
|
||||||
|
import yaml, re, pathlib
|
||||||
|
content = pathlib.Path("skills/<category>/<name>/SKILL.md").read_text()
|
||||||
|
assert content.startswith("---")
|
||||||
|
m = re.search(r'\n---\s*\n', content[3:])
|
||||||
|
fm = yaml.safe_load(content[3:m.start()+3])
|
||||||
|
assert "name" in fm and "description" in fm
|
||||||
|
assert len(fm["description"]) <= 1024
|
||||||
|
assert len(content) <= 100_000
|
||||||
|
```
|
||||||
|
5. **Git add + commit** on the active branch.
|
||||||
|
6. **Note:** the CURRENT session's skill loader is cached — `skill_view` / `skills_list` will not see the new skill until a new session. This is expected, not a bug.
|
||||||
|
|
||||||
|
## Cross-Referencing Other Skills
|
||||||
|
|
||||||
|
`metadata.hermes.related_skills` unions both trees (`skills/` in-repo and `~/.hermes/skills/`) at load time. You CAN reference a user-local skill from an in-repo skill, but it won't resolve for other users who clone the repo fresh. Prefer referencing only in-repo skills from in-repo skills. If a frequently-referenced skill lives only in `~/.hermes/skills/`, consider promoting it to the repo.
|
||||||
|
|
||||||
|
## Editing Existing In-Repo Skills
|
||||||
|
|
||||||
|
- **Small fix (typo, added pitfall, tightened trigger):** `skill_manage(action='patch', name=..., old_string=..., new_string=...)` works fine on in-repo skills.
|
||||||
|
- **Major rewrite:** `write_file` the whole SKILL.md. `skill_manage(action='edit')` also works but requires supplying the full new content.
|
||||||
|
- **Adding supporting files:** `write_file` to `skills/<category>/<name>/references/<file>.md`, `templates/<file>`, or `scripts/<file>`. `skill_manage(action='write_file')` also works and enforces the references/templates/scripts/assets subdir allowlist.
|
||||||
|
- **Always commit** the edit — in-repo skills are source, not runtime state.
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
1. **Using `skill_manage(action='create')` for an in-repo skill.** It writes to `~/.hermes/skills/`, not the repo tree. Use `write_file` for in-repo creation.
|
||||||
|
|
||||||
|
2. **Leading whitespace before `---`.** The validator checks `content.startswith("---")`; any leading blank line or BOM fails validation.
|
||||||
|
|
||||||
|
3. **Description too generic.** Peer descriptions start with "Use when ..." and describe the *trigger class*, not the one task. "Use when debugging X" > "Debug X".
|
||||||
|
|
||||||
|
4. **Forgetting the author/license/metadata block.** Not validator-enforced, but every peer has it; omitting makes the skill look half-finished.
|
||||||
|
|
||||||
|
5. **Writing a skill that duplicates a peer.** Before creating, `ls skills/<category>/` and open 2-3 peers. Prefer extending an existing skill to creating a narrow sibling.
|
||||||
|
|
||||||
|
6. **Expecting the current session to see the new skill.** It won't. The skill loader is initialized at session start. Verify in a fresh session or via `skill_view` using the exact path.
|
||||||
|
|
||||||
|
7. **Linking to skills that don't exist in-repo.** `related_skills: [some-user-local-skill]` works for you but breaks for other clones. Prefer only in-repo links.
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
- [ ] File is at `skills/<category>/<name>/SKILL.md` (not in `~/.hermes/skills/`)
|
||||||
|
- [ ] Frontmatter starts at byte 0 with `---`, closes with `\n---\n`
|
||||||
|
- [ ] `name`, `description`, `version`, `author`, `license`, `metadata.hermes.{tags, related_skills}` all present
|
||||||
|
- [ ] Name ≤ 64 chars, lowercase + hyphens
|
||||||
|
- [ ] Description ≤ 1024 chars and starts with "Use when ..."
|
||||||
|
- [ ] Total file ≤ 100,000 chars (aim for 8-15k)
|
||||||
|
- [ ] Structure: `# Title` → `## Overview` → `## When to Use` → body → `## Common Pitfalls` → `## Verification Checklist`
|
||||||
|
- [ ] `related_skills` references resolve in-repo (or are explicitly OK to be user-local)
|
||||||
|
- [ ] `git add skills/<category>/<name>/ && git commit` completed on the intended branch
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Webhook Subscriptions
|
||||||
|
|
||||||
|
Create dynamic webhook subscriptions so external services (GitHub, GitLab, Stripe, CI/CD, IoT sensors, monitoring tools) can trigger Hermes agent runs by POSTing events to a URL.
|
||||||
|
|
||||||
|
## Setup (Required First)
|
||||||
|
|
||||||
|
The webhook platform must be enabled before subscriptions can be created. Check with:
|
||||||
|
```bash
|
||||||
|
hermes webhook list
|
||||||
|
```
|
||||||
|
|
||||||
|
If it says "Webhook platform is not enabled", set it up:
|
||||||
|
|
||||||
|
### Option 1: Setup wizard
|
||||||
|
```bash
|
||||||
|
hermes gateway setup
|
||||||
|
```
|
||||||
|
Follow the prompts to enable webhooks, set the port, and set a global HMAC secret.
|
||||||
|
|
||||||
|
### Option 2: Manual config
|
||||||
|
Add to `~/.hermes/config.yaml`:
|
||||||
|
```yaml
|
||||||
|
platforms:
|
||||||
|
webhook:
|
||||||
|
enabled: true
|
||||||
|
extra:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 8644
|
||||||
|
secret: "generate-a-strong-secret-here"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Environment variables
|
||||||
|
Add to `~/.hermes/.env`:
|
||||||
|
```bash
|
||||||
|
WEBHOOK_ENABLED=true
|
||||||
|
WEBHOOK_PORT=8644
|
||||||
|
WEBHOOK_SECRET=generate-a-strong-secret-here
|
||||||
|
```
|
||||||
|
|
||||||
|
After configuration, start (or restart) the gateway:
|
||||||
|
```bash
|
||||||
|
hermes gateway run
|
||||||
|
# Or if using systemd:
|
||||||
|
systemctl --user restart hermes-gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify it's running:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8644/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
All management is via the `hermes webhook` CLI command:
|
||||||
|
|
||||||
|
### Create a subscription
|
||||||
|
```bash
|
||||||
|
hermes webhook subscribe <name> \
|
||||||
|
--prompt "Prompt template with {payload.fields}" \
|
||||||
|
--events "event1,event2" \
|
||||||
|
--description "What this does" \
|
||||||
|
--skills "skill1,skill2" \
|
||||||
|
--deliver telegram \
|
||||||
|
--deliver-chat-id "12345" \
|
||||||
|
--secret "optional-custom-secret"
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the webhook URL and HMAC secret. The user configures their service to POST to that URL.
|
||||||
|
|
||||||
|
### List subscriptions
|
||||||
|
```bash
|
||||||
|
hermes webhook list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove a subscription
|
||||||
|
```bash
|
||||||
|
hermes webhook remove <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test a subscription
|
||||||
|
```bash
|
||||||
|
hermes webhook test <name>
|
||||||
|
hermes webhook test <name> --payload '{"key": "value"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prompt Templates
|
||||||
|
|
||||||
|
Prompts support `{dot.notation}` for accessing nested payload fields:
|
||||||
|
|
||||||
|
- `{issue.title}` — GitHub issue title
|
||||||
|
- `{pull_request.user.login}` — PR author
|
||||||
|
- `{data.object.amount}` — Stripe payment amount
|
||||||
|
- `{sensor.temperature}` — IoT sensor reading
|
||||||
|
|
||||||
|
If no prompt is specified, the full JSON payload is dumped into the agent prompt.
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### GitHub: new issues
|
||||||
|
```bash
|
||||||
|
hermes webhook subscribe github-issues \
|
||||||
|
--events "issues" \
|
||||||
|
--prompt "New GitHub issue #{issue.number}: {issue.title}\n\nAction: {action}\nAuthor: {issue.user.login}\nBody:\n{issue.body}\n\nPlease triage this issue." \
|
||||||
|
--deliver telegram \
|
||||||
|
--deliver-chat-id "-100123456789"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in GitHub repo Settings → Webhooks → Add webhook:
|
||||||
|
- Payload URL: the returned webhook_url
|
||||||
|
- Content type: application/json
|
||||||
|
- Secret: the returned secret
|
||||||
|
- Events: "Issues"
|
||||||
|
|
||||||
|
### GitHub: PR reviews
|
||||||
|
```bash
|
||||||
|
hermes webhook subscribe github-prs \
|
||||||
|
--events "pull_request" \
|
||||||
|
--prompt "PR #{pull_request.number} {action}: {pull_request.title}\nBy: {pull_request.user.login}\nBranch: {pull_request.head.ref}\n\n{pull_request.body}" \
|
||||||
|
--skills "github-code-review" \
|
||||||
|
--deliver github_comment
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stripe: payment events
|
||||||
|
```bash
|
||||||
|
hermes webhook subscribe stripe-payments \
|
||||||
|
--events "payment_intent.succeeded,payment_intent.payment_failed" \
|
||||||
|
--prompt "Payment {data.object.status}: {data.object.amount} cents from {data.object.receipt_email}" \
|
||||||
|
--deliver telegram \
|
||||||
|
--deliver-chat-id "-100123456789"
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI/CD: build notifications
|
||||||
|
```bash
|
||||||
|
hermes webhook subscribe ci-builds \
|
||||||
|
--events "pipeline" \
|
||||||
|
--prompt "Build {object_attributes.status} on {project.name} branch {object_attributes.ref}\nCommit: {commit.message}" \
|
||||||
|
--deliver discord \
|
||||||
|
--deliver-chat-id "1234567890"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generic monitoring alert
|
||||||
|
```bash
|
||||||
|
hermes webhook subscribe alerts \
|
||||||
|
--prompt "Alert: {alert.name}\nSeverity: {alert.severity}\nMessage: {alert.message}\n\nPlease investigate and suggest remediation." \
|
||||||
|
--deliver origin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct delivery (no agent, zero LLM cost)
|
||||||
|
|
||||||
|
For use cases where you just want to push a notification through to a user's chat — no reasoning, no agent loop — add `--deliver-only`. The rendered `--prompt` template becomes the literal message body and is dispatched directly to the target adapter.
|
||||||
|
|
||||||
|
Use this for:
|
||||||
|
- External service push notifications (Supabase/Firebase webhooks → Telegram)
|
||||||
|
- Monitoring alerts that should forward verbatim
|
||||||
|
- Inter-agent pings where one agent is telling another agent's user something
|
||||||
|
- Any webhook where an LLM round trip would be wasted effort
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes webhook subscribe antenna-matches \
|
||||||
|
--deliver telegram \
|
||||||
|
--deliver-chat-id "123456789" \
|
||||||
|
--deliver-only \
|
||||||
|
--prompt "🎉 New match: {match.user_name} matched with you!" \
|
||||||
|
--description "Antenna match notifications"
|
||||||
|
```
|
||||||
|
|
||||||
|
The POST returns `200 OK` on successful delivery, `502` on target failure — so upstream services can retry intelligently. HMAC auth, rate limits, and idempotency still apply.
|
||||||
|
|
||||||
|
Requires `--deliver` to be a real target (telegram, discord, slack, github_comment, etc.) — `--deliver log` is rejected because log-only direct delivery is pointless.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Each subscription gets an auto-generated HMAC-SHA256 secret (or provide your own with `--secret`)
|
||||||
|
- The webhook adapter validates signatures on every incoming POST
|
||||||
|
- Static routes from config.yaml cannot be overwritten by dynamic subscriptions
|
||||||
|
- Subscriptions persist to `~/.hermes/webhook_subscriptions.json`
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. `hermes webhook subscribe` writes to `~/.hermes/webhook_subscriptions.json`
|
||||||
|
2. The webhook adapter hot-reloads this file on each incoming request (mtime-gated, negligible overhead)
|
||||||
|
3. When a POST arrives matching a route, the adapter formats the prompt and triggers an agent run
|
||||||
|
4. The agent's response is delivered to the configured target (Telegram, Discord, GitHub comment, etc.)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If webhooks aren't working:
|
||||||
|
|
||||||
|
1. **Is the gateway running?** Check with `systemctl --user status hermes-gateway` or `ps aux | grep gateway`
|
||||||
|
2. **Is the webhook server listening?** `curl http://localhost:8644/health` should return `{"status": "ok"}`
|
||||||
|
3. **Check gateway logs:** `grep webhook ~/.hermes/logs/gateway.log | tail -20`
|
||||||
|
4. **Signature mismatch?** Verify the secret in your service matches the one from `hermes webhook list`. GitHub sends `X-Hub-Signature-256`, GitLab sends `X-Gitlab-Token`.
|
||||||
|
5. **Firewall/NAT?** The webhook URL must be reachable from the service. For local development, use a tunnel (ngrok, cloudflared).
|
||||||
|
6. **Wrong event type?** Check `--events` filter matches what the service sends. Use `hermes webhook test <name>` to verify the route works.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
name: multichannel-messaging-discipline
|
||||||
|
description: 跨渠道、多人环境下的通讯纪律——回复路由(在哪个渠道被找就回哪个渠道)、发送者身份核实、信息隔离、发送前核对。在向任何人或群发消息/回复、不确定对方是谁、或决定通知发往何处之前加载。覆盖企微 + 飞书的群聊与私聊。Load before sending any WeCom/Feishu message (group or DM), when unsure who the sender is, or when deciding where a reply/notification should go.
|
||||||
|
---
|
||||||
|
|
||||||
|
# 多渠道通讯纪律 (Multichannel Messaging Discipline)
|
||||||
|
|
||||||
|
本环境同时有 **企微 + 飞书**、多个群、私聊、多个互不交叉的人(Doro、Maggie、邱律师、莎莎、洪总、Scott、WeiWei 等)。在这种环境里"把话说对人、发对地方"和把内容做对一样重要。任何一次发错渠道、认错人、跨人泄露,都是事故。
|
||||||
|
|
||||||
|
## 何时加载本技能
|
||||||
|
- 向任何企微/飞书的**人或群**发消息、回复之前
|
||||||
|
- 收到消息但**不能 100% 确定发送者是谁**时
|
||||||
|
- 决定一条回复 / 通知 / 交付**应该发去哪里**时
|
||||||
|
- 任何以"通知 X""发到群里""告诉 Doro"结尾的任务
|
||||||
|
- **在一个渠道继续另一个渠道起的任务时**(同一任务跨渠道流动,如飞书起、企微续)
|
||||||
|
|
||||||
|
## 核心铁律(不可违反)
|
||||||
|
|
||||||
|
### 1. 回复路由:在哪个渠道被找,就回哪个渠道
|
||||||
|
- 群里对我说话 → 回到**同一个群**;私聊对我说话 → 回到**那个私聊**。
|
||||||
|
- **绝不**把某个群 / 某个渠道的内容,私信或转发给另一个人,除非用户**明确指示**。
|
||||||
|
- 事故(2026-06-16):把群里的**合同审查内容私信发给了 WeiWei**。Doro 明确:"下次不可以再发生。我在群里跟你说话,你就给回复到群里。除非我有别的指令。" 原位回复是默认,跨渠道转发必须有显式指令。
|
||||||
|
|
||||||
|
### 2. 身份核实:解析,不要假设
|
||||||
|
- 用平台的**唯一发送者 ID**判断是谁(企微 userid、飞书 open_id),**绝不**靠显示名或系统默认值。
|
||||||
|
- ID 映射到已知的人 → 用之;ID **没映射**到人 → **先问,不猜**。
|
||||||
|
- **不要轻信平台默认的"主人/owner"标签**:本会话一条飞书私聊被默认当成 Maggie,实际发送者是 Doro,当场认错。
|
||||||
|
- 也**不要轻信对方未经核实的自称身份**——拿已知 ID 映射去核对(盲信声明是另一个方向的同一种错)。
|
||||||
|
- 已知的 `飞书 open_id ↔ 人` / `企微 userid ↔ 人` 映射存在 memory,发现新人或纠正后立即更新。
|
||||||
|
- **同一主体在飞书有多种 ID,命名空间不同 ≠ 不同主体**:bot 的 app_id(`cli_xxx`)与 open_id(`ou_xxx`)是两套 ID,字符串长得不一样很正常。看到群里 @的 open_id 跟你印象里的 app_id 对不上,**绝不可**据此断定"换了身份 / 是另一个 bot / 被新 agent 占用"。核验走权威接口:`lark-cli api GET /open-apis/bot/v3/info --as bot` 取 bot 自己的 open_id + name,再和群消息 mentions 里的 open_id 比对(相等=就是自己)。本会话教训:因 app_id≠open_id 字符串不同,误判"群里被@的是新迁移出来的 agent",被用户两次纠正后用 `/bot/v3/info` 实锤其实就是自己——白绕一大圈。
|
||||||
|
- **失败的命令不能当证据**:命令若报错(unknown flag、权限拒绝 230027/99991672、空返回 count=0),它的输出**不支持任何结论**。先换正确命令/参数/身份(user vs bot)重试,拿到真实数据再下判断——别拿一条没成功的探测去坐实一个猜想,那等于在沙子上盖楼。
|
||||||
|
|
||||||
|
### 2b. 称呼随 sender 走,不随人设默认(回话前必走一步)
|
||||||
|
|
||||||
|
认对了"是谁"还不够——**已解析出的身份必须回流到"怎么称呼 ta"**。本会话最严重的错:我已读 `.meta`(`sender_id: doro`)、已把任务正确归类为 Doro 批量线,却在整份交付报告里从头到尾把 Doro 叫成"Maggie"。识别对了归属、却叫错人 = 信息在手却没整合,是独立于"认错人"的一种失败。
|
||||||
|
|
||||||
|
**机制根因**:系统人设里硬编码了"主人叫 Maggie、称呼主人为 Maggie"。这条规则有个**隐含前提——当前对话者确实是主人本人**。把它当成无条件默认,就会用人设里的高频人名锚点覆盖掉会话事实。
|
||||||
|
|
||||||
|
**回话前的强制自检(每次生成称呼前走一遍)**:
|
||||||
|
1. 当前这条消息的 **sender 是谁**?取 `.meta` 的 `sender_id`,或会话 `Source / User`,**不取人设默认名**。
|
||||||
|
2. sender **是主人本人吗**?是 → 才用主人称呼(Maggie);**否** → 用该 sender 的真实身份(`doro → Doro`、`qiuting → 邱律师` 等)。
|
||||||
|
3. 已核实的任务归属身份与称呼**必须一致**——不能归属判对了、抬头却写错。
|
||||||
|
|
||||||
|
口诀:**人设默认名是"当对方是主人时"的条件值,不是无条件抬头。回话前先认 sender,再决定称呼。**
|
||||||
|
|
||||||
|
### 3. 信息隔离
|
||||||
|
- 每个人 / 每个团队的文件和任务,只留在该人 / 该团队的上下文里。绝不把一方的内容带进另一方的渠道,也不在 A 的群里提 B 的任务。
|
||||||
|
- Maggie 团队 与 Doro/邱律师 团队、各客户之间,严格不交叉。
|
||||||
|
|
||||||
|
### 3b. 跨渠道任务连续性(同一任务在两个渠道间流动时)
|
||||||
|
同一个项目/任务可能在多个渠道接力推进——典型:飞书上确认了方法、更新了交付物,然后转到企微继续。**记忆是跨渠道通的(用户偏好、规则、文件位置都在),但每个渠道的对话上下文是隔离的**:飞书刚说的话不会自动进企微会话。所以\"接力续做\"前必须主动对齐,否则会拿旧认知在新渠道做事、重复已完成的工作、或漏掉刚确认的结论。
|
||||||
|
|
||||||
|
**续做铁律:先对齐,再动手。**
|
||||||
|
1. **用 `session_search` 把另一渠道的最新成果拉回来**——搜项目名/关键结论(如\"世茂 千分号 汇总表\"),读出对方渠道刚确认了什么、交付物更新到哪一版。不要凭这边的旧记忆假设进度。
|
||||||
|
2. **从权威源核实交付物现状**,不信任\"我记得\"——交付物(Nextcloud xlsx/docx)以实际文件为准,拉最新版打开看,确认版本/时间戳/内容与对方渠道的结论一致。
|
||||||
|
3. **回应用户关心的具体修改/结论时,先复述对齐结果**(\"飞书那边刚确认的 X、更新的 Y 我已接收\"),让用户确认我们站在同一起点,再往下做。
|
||||||
|
4. **渠道中断会留缺口**:若某渠道掉线过(如企微群订阅失效两小时),那段时间该渠道里别人发的内容这边是空白。续做前主动提示用户\"X 时段后若有人在该渠道发过东西,我没收到,请补给我\",避免任务断档——尤其客户对接渠道(隔离铁律下漏收=对接出窟窿)。
|
||||||
|
|
||||||
|
> 实证(2026-06-18 南通新东方/世茂):用户在飞书确认了 OCR 千分号/百分号识别法 + 更新了世茂汇总表,然后转企微说\"继续过世茂合同\"。正确做法是先 session_search 把飞书成果对齐、从 Nextcloud 拉最新版 xlsx 读出 4 份合同结构,再请用户给逐条指令——而不是在企微凭旧上下文直接开干。同期企微群因订阅失效(errcode 846609)静默两小时,须提示用户补回漏收的群消息。
|
||||||
|
|
||||||
|
### 4. 发送前三项核对(任何别人能看到的消息)
|
||||||
|
发出去前确认:
|
||||||
|
1. **收件人**——确切的人或群,解析出 chat_id / user_id(不要凭印象挑一个)
|
||||||
|
2. **内容**——要发的正文
|
||||||
|
3. **以谁的身份发**——bot 还是 user
|
||||||
|
工具支持时**先 dry-run**,确认 payload 无误再正式发;发完把回执(message_id + 北京时间)报给用户便于核对。
|
||||||
|
|
||||||
|
### 5. 平台内账号/身份管理请求:把它当成“账号操作”,不是“消息回复”
|
||||||
|
当用户让你**登录某个平台上的账号、修改密码、完成首次登录、维护个人账号资料**时,默认这是一个**账号操作任务**,不是跨人沟通任务。此时重点从“回哪个群/私聊”切换为:
|
||||||
|
|
||||||
|
1. **先区分是否是“当前 agent 自己要持有的账号”**
|
||||||
|
- 用户明确说“这是你的账号”“你自己管理好密码” → 可由 agent 自行设置并保管该账号密码。
|
||||||
|
- 如果是代表某个真人同事/用户持有的账号,且对方未授权 agent 自定密码 → 不能擅自决定长期密码。
|
||||||
|
2. **执行后要给结果,不要停在征求式废话**
|
||||||
|
- 用户已明确授权 agent 自主管理密码时,直接完成修改并回报“已完成”。
|
||||||
|
- 不要在已获授权的情况下继续追问“你想设什么密码”。
|
||||||
|
3. **账号管理与消息路由隔离**
|
||||||
|
- 账号是平台内身份,不等于消息发送对象;不要因为会话对方是谁,就把账号密码策略误当成需要对方逐项确认的沟通动作。
|
||||||
|
4. **汇报风格要结果导向**
|
||||||
|
- 这类任务完成后,优先回:是否登录成功、是否已改密、页面确认信息。
|
||||||
|
- 不展开无关解释,避免把简单账号操作说成审批流程。
|
||||||
|
|
||||||
|
### 5. 用户要求“重新查 / 不要用记忆 / 用文件夹里的问题件”时,立即切换到证据模式
|
||||||
|
这类指令不是语气提醒,而是**明确纠偏**:之前的回答被认为混入了记忆、推断或口径漂移。后续必须把“回复别人”切回“基于当前文件/当前目录/当前记录的实查结果”。
|
||||||
|
|
||||||
|
执行要求:
|
||||||
|
1. **停止沿用上一条摘要口径**——哪怕上一条是自己刚写的,也不能继续复述;
|
||||||
|
2. **以用户指定的载体为准重新取证**:
|
||||||
|
- 说“用文件夹里的问题件” → 先看该文件夹实际有哪些文件;
|
||||||
|
- 说“打开文件查” → 必须打开文件或其内部结构(如 docx XML)再说;
|
||||||
|
- 说“不要用记忆” → 禁止用 memory / 旧会话印象补缀事实;
|
||||||
|
3. **结论按证据强弱分层表达**:
|
||||||
|
- 能被当前文件直接坐实的,就说“可确认”;
|
||||||
|
- 只能证明“文件存在”但不能证明“属于该批问题件/pass件”的,就明确写“目前只能确认存在,不能据此归类”;
|
||||||
|
4. **不要把“在任务交付目录里存在”偷换成“就是问题件 / 已 pass / 属于同一批”**;
|
||||||
|
5. **汇报时先交代证据来源**(查了哪个目录、哪份问题汇总、是否打开了文件),再给结论。
|
||||||
|
6. **用户说“我就要一个结果”时,停止过程化解释**:这类话表示对方当前只接受最终答案,不要再补背景、过程、严谨性铺垫。先给一句结论;如对方追问,再展开证据链。
|
||||||
|
|
||||||
|
一句话:**用户说“重新查”时,先把脑子里的版本清空,回到文件本身;用户说“只要结果”时,先把话收成结论。**
|
||||||
|
|
||||||
|
## 实操配方
|
||||||
|
- **飞书群里接收文件/图片**:飞书不允许文件和文字(@mention)在同一条消息里。解决方案:用户先发文件/图片,再对那条消息点「回复」并在回复里@小 Maggie。Gateway 的 `_fetch_parent_media` 方法会自动从被引用的 parent 消息中下载附件(2026-07-11 补丁)。文件缓存路径:`~/.hermes/cache/documents/`。图片同理——用户发图后回复+@即可。
|
||||||
|
- 飞书群发消息 + @人(找群、@格式、dry-run、核对回执):见 `references/feishu-group-send-and-mention.md`
|
||||||
|
- **飞书 bot 在群里不回、私聊却正常**的诊断配方(gateway inbound vs 群真实历史对照、查 `mentions[].id` 的 open_id 识别同名 bot 撞名、`--as user` 读群历史):见 `references/feishu-bot-silent-in-group-diagnosis.md`
|
||||||
|
- 企微发文件 / 私信机制:见 `wecom-file-send-receive` 技能(MEDIA: 标签发文件;私信走独立 WS aibot_send_msg,chatid=userid+chat_type=1)
|
||||||
|
- **企微私信非 home 的人(Doro/邱律师/WeiWei 等)→ 用 `~/.hermes/scripts/wecom_dm.py`,不要用 `send_message` 工具**:
|
||||||
|
- `send_message(target='wecom:WeiWei')` 对非 home 的 wecom 用户会**静默回退到 home channel**(实测返回 `chat_id: JiaQian` + note `Sent to wecom home channel`),**不报错**——一条发给 WeiWei 的技术讨论就这样落到了 Maggie 渠道,同时违反信息隔离。
|
||||||
|
- 正确做法(agent 可直接 terminal 调用,自开 WS、永不退化成回复、目标唯一):
|
||||||
|
```bash
|
||||||
|
python3 ~/.hermes/scripts/wecom_dm.py --list # 先看白名单别名↔userid
|
||||||
|
python3 ~/.hermes/scripts/wecom_dm.py --to WeiWei --text "…" --dry-run # 演练
|
||||||
|
python3 ~/.hermes/scripts/wecom_dm.py --to WeiWei --text "…" # 真发,回执含 message_id
|
||||||
|
```
|
||||||
|
- 别名:`doro / jiaqian / qiuting / weiwei / shasha / yangayi / xiaonan`(`--list` 为准)。这是 `references/wecom-proactive-notify-misrouting.md` 修复方向 A 在脚本层的现成实现,**比 `_send_wecom(extra,…)` 更可用**(后者需 gateway 内部 `extra`,agent 会话里拿不到)。
|
||||||
|
- 企微主动通知**错投到错误的人**("给 Doro 的消息发给了 Maggie")的根因 + 只读诊断配方 + 修复方向:见 `references/wecom-proactive-notify-misrouting.md`
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
- **把群任务的处理结果私信给"相关的人"**——即使你觉得对方该知道,也不行。原位回群,要不要另外通知由用户决定。
|
||||||
|
- **靠会话默认值认人**——私聊默认 owner 不等于当前发送者,必须看 sender ID。
|
||||||
|
- **拿\"两个长得不一样的 ID 字符串\"推断成\"两个身份/新迁移出的 agent\"**——同一个实体在不同命名空间有多种 ID,长相不同是常态:飞书机器人的 `app_id`(`cli_xxx`)与它的 `open_id`(`ou_xxx`)本就不一样,**两者是同一个 bot**。事故(2026-06-22 与 WeiWei 排障):我把群里被 @ 的 `ou_20bd8…`(open_id)和 gateway 配置里的 `cli_aaa4e77d27789bed`(app_id)当成两个 bot,进而臆断\"有个新迁移出来的 agent 占用了身份\",被纠正\"迁移还没开始\"。下结论前用**权威身份接口**核对:`LARK_CLI_NO_PROXY=1 lark-cli api GET /open-apis/bot/v3/info --as bot` 返回的 `open_id`/`app_name` 才是 bot 真身——拿它去比对,再判断是不是同一个。
|
||||||
|
- **从一条 errored 的命令里读出\"结论\"**——同一次排障里,我那条查 bot open_id 的命令其实用错了 flag、根本没返回结果,我却继续往\"新身份\"上跳。**命令报错 = 没有证据,不是证据**;拿到真实返回再推理,别把工具失败当成支持自己假设的信号。
|
||||||
|
- **同名 bot 撞名 = @ 显示名 ≠ @ 到你这个 app**:群里可能存在两个同名「小Maggie」(典型触发:做过新 Agent 迁移 / provisioning,新身份被拉进群)。用户 @ 显示名时飞书解析到的是某个 open_id,若那不是当前 gateway 跑的 app_id,事件流根本收不到,bot"静默不回",但 DM 仍正常(DM 按会话路由、不按 @ 身份)。诊断时必须查群消息 `mentions[].id` 的 open_id 和当前 bot app_id 是否一致,别一看不回就报"掉线"。完整配方见 `references/feishu-bot-silent-in-group-diagnosis.md`。
|
||||||
|
- **认对了人却叫错称呼**——已解析出 sender 是 Doro,抬头却写"Maggie"。人设里"主人叫 Maggie"是"当对方是主人时"的条件值,不是无条件抬头;归属身份必须回流到称呼(见 2b)。
|
||||||
|
- **session 无 .meta / sender 信息时,从文件内容或"印象"推断发件人**——事故(2026-07-13):session JSON 的 user message 里无 sender_id metadata,我从合同内容(青浦区卫生机构)推断是"刘婷律师",实际 gateway 日志明确显示 `user=QiuTing`(邱律师)。**当 session 记录不含 sender 信息时,必须查 gateway 日志(`~/.hermes/logs/gateway.log`)确认 `inbound message: platform=wecom user=XXX` 才能定身份**,绝不可从文件内容、以往经验、或"谁经常发这类合同"去猜。`grep "时间段" ~/.hermes/logs/gateway.log | grep "inbound"` 是唯一权威来源。
|
||||||
|
- **猜 chat_id / group**——发前用 `lark-cli im +chat-list --as bot` 把 bot 实际所在的群列出来,挑出你和目标人共处的那个,别凭记忆。
|
||||||
|
- **企微 @通知**:aibot_send_msg 只支持 markdown,不支持 text+mentioned_list,无法真正 @人;需要提醒时另发一条私信利用其消息提醒(仅在用户允许、且不违反路由铁律的前提下)。
|
||||||
|
- **自动化脚本会替你发消息**:`auto_notify_new_file.sh` 等脚本会自动私信。处理任务前留意有没有自动化机制正在按旧规则推送,避免它替你违反路由 / 隔离铁律。
|
||||||
|
- **`wecom_group_notify.py` 和 `wecom_dm.py` 是两个完全不同的脚本,不可混用(2026-07-01教训)**:Doro说"私信邱律师",用了 `wecom_group_notify.py`(默认发到批量合同审查群 `wrbAFkXAAAiWC3styKqNj0bZyH6BbJ_Q`),消息发到了群里而不是邱律师私信。**发前看清脚本名**:`wecom_dm.py` = 私信;`wecom_group_notify.py` = 群通知。workflow YAML 中通知邱律师的脚本也必须用 `wecom_dm.py --to qiuting`,不是 `wecom_group_notify.py`。
|
||||||
|
- **`send_message` 工具私信企微人会静默投错**:`send_message(target='wecom:<user>')` 对非 home channel 的 wecom 用户**不报错、直接回退到 home channel**(实测发给 WeiWei 却落到 JiaQian/Maggie)。这是单点事故——既没到目标人、又跨团队泄露。私信非 home 的企微人一律走 `~/.hermes/scripts/wecom_dm.py --to <alias>`(见上"实操配方"),别用 `send_message`。发完核回执里的 userid 是不是目标人,发现是 home channel 立即用脚本补发。
|
||||||
|
- **"给 X 的消息错投给 Y"先查 adapter 回复兜底,别先怪 userid**:企微 `WeComAdapter.send()` 在主动 `aibot_send_msg` 前有一段 `_last_chat_req_ids[chat_id]` 回复兜底——主动私信可能**退化成"回复某条历史消息"**,在多人并发的长跑 gateway 里串到别人头上。userid 往往是对的(`doro/JiaQian/QiuTing` 都是独立真实 ID),错在路径。诊断配方 + 根因 + 三个修复方向见 `references/wecom-proactive-notify-misrouting.md`。注意 `workflow-watchdog.sh` 仍硬编码 `_send_wecom(extra,'doro',msg)`,是未拆的隐患。
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
# 飞书 bot 在群里静默、私聊却正常 —— 诊断配方
|
||||||
|
|
||||||
|
实证:2026-06-22 群「小Maggie工作群」(`oc_a927f86118216c36cb9394b0e95f2a11`)。WeiWei/颜伽艺在群里 @小Maggie 无反应,私聊正常。
|
||||||
|
|
||||||
|
## 症状
|
||||||
|
- 用户在飞书**群**里 @小Maggie,bot 不回。
|
||||||
|
- 但**私聊**(DM)发消息 bot 正常回复。
|
||||||
|
- gateway 进程活着,飞书连接日志显示 `[Feishu] Connected in websocket mode`。
|
||||||
|
|
||||||
|
## 根因类别(按概率排序)
|
||||||
|
1. **身份不匹配(display-name 撞名)** — 群里被 @ 的「小Maggie」其实是**另一个 bot 身份**(不同 open_id),不是当前 gateway 跑的那个 app。常见触发:做过「新 Agent 迁移 / provisioning」,新身份被拉进群并占用了群里「小Maggie」的 @ 目标。群 @ 解析到新身份的 open_id,旧 gateway(旧 app_id)的事件流里根本收不到这条,所以"不回"。**DM 仍正常,因为 DM 按会话路由、不按 @ 身份。** ← 本会话确诊就是这一类。
|
||||||
|
2. 订阅/连接在空窗后失效(同类:企微 errcode 846609 静默两小时)。
|
||||||
|
3. 发送侧失败:历史上见过 `[99992402] field validation failed`(连 plain-text 兜底也失败)——这是**发**不出去,不是**收**不到,必须区分。
|
||||||
|
|
||||||
|
## 诊断步骤(命令均已实测可用)
|
||||||
|
所有 lark-cli 命令加 `LARK_CLI_NO_PROXY=1` 前缀,避免凭据走 HTTPS_PROXY。
|
||||||
|
|
||||||
|
**1. 确认 bot 在哪些群、拿群 chat_id:**
|
||||||
|
```bash
|
||||||
|
LARK_CLI_NO_PROXY=1 lark-cli im +chat-list --as bot
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 查 gateway 实际收到了这个群的哪些 inbound:**
|
||||||
|
```bash
|
||||||
|
grep "oc_<群id>" ~/.hermes/logs/gateway.log | grep -iE "Inbound|inbound message"
|
||||||
|
```
|
||||||
|
若最后一条 inbound 停在很久以前、之后空白 → gateway 根本没收到新群消息(排除"收到但没回")。
|
||||||
|
|
||||||
|
**3. 拉群的真实消息历史,和第 2 步对照:**
|
||||||
|
> bot 身份读群历史会因缺 scope 失败:`+chat-messages-list --as bot` → 230027 / 99991672,缺 `im:chat:readonly` `im:chat.members:read`。**改用 `--as user`**。
|
||||||
|
```bash
|
||||||
|
LARK_CLI_NO_PROXY=1 lark-cli im +chat-messages-list \
|
||||||
|
--chat-id oc_<群id> --as user --order desc --page-size 30 --no-reactions --format json
|
||||||
|
```
|
||||||
|
返回 JSON 字段:`data.messages[]`,每条含 `content`(直接是文本)、`create_time`、`sender.{id,sender_type,name}`、`mentions[].{id,name}`。**注意不是 `items`/`body`** —— 用错字段会得到 `count=0` 的假空,误判成"群里没人发"。
|
||||||
|
|
||||||
|
**4. 关键判定 —— 看 @ 到的是谁的 open_id:**
|
||||||
|
群消息的 `mentions[].id` 就是被 @ 对象的 open_id。和当前 gateway 的 bot 身份对比:
|
||||||
|
```bash
|
||||||
|
# 当前 gateway 用的飞书 app_id
|
||||||
|
python3 -c "import yaml;c=yaml.safe_load(open('/home/maggie/.hermes/config.yaml'));print(c['gateway']['platforms']['feishu'].get('extra',{}).get('app_id'))"
|
||||||
|
```
|
||||||
|
- 对照法:第 2 步里 gateway 正常工作时段,bot 在群里发言的 sender 是 `app cli_<app_id>`。拿这个和第 4 步 mentions 里「小Maggie」的 open_id 比。
|
||||||
|
- 若群历史里「小Maggie」被 @ 的 open_id ≠ 当前 gateway bot 的身份 → **确诊身份不匹配**:群里有两个同名「小Maggie」,大家 @ 错了。
|
||||||
|
|
||||||
|
**5. 旁证:DM 是否正常。** 私聊 inbound 在 gateway.log 里照常出现 → 证明连接/订阅活着,问题收敛到"群 @ 身份"这一层。
|
||||||
|
|
||||||
|
## 结论怎么报
|
||||||
|
- 这是技术/配置问题,按铁律修复决策交给技术负责人(WeiWei/Scott),不自作主张改。
|
||||||
|
- 给三个方向:①新 Agent 接管群(@ 目标对到新身份 / 把新身份订阅配通);②仍由旧 bot 管群(移除或换回群里的新「小Maggie」);③过渡期走私聊。
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
- **别一看 bot 没回就说"掉线了"** —— 先分清"没收到(群 @ 身份不对 / 订阅失效)" vs "收到但没发出去(send 失败 99992402)"。
|
||||||
|
- **lark-cli 读群历史/群成员要用 `--as user`**,bot 身份缺 scope。要让 bot 自己能读,得在开放平台给 `cli_xxx` 补 `im:chat:readonly` `im:chat.group_info:readonly` `im:chat.members:read`。
|
||||||
|
- **JSON 字段名**:`+chat-messages-list` 返回 `data.messages[].content`,不是 `items[].body`。用错字段会得到误导性的 `count=0`。
|
||||||
|
- **重启时 DNS 抽风是环境问题,不是代码问题** —— 重启日志里若有 `Temporary failure in name resolution`(open.feishu.cn / openws.work.weixin.qq.com),那是当时网络/DNS 短暂故障;飞书多半自己重连上了,企微可能没重连成功,单独核实企微在线状态即可,别当成代码 bug 去改。
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
# 飞书群发消息 + @人(已验证配方 2026-06-16)
|
||||||
|
|
||||||
|
目标:把消息发到**正确的飞书群**并 **@ 正确的人**,一次做对。依赖 `lark-cli`(lark-im 技能)。
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
### 1. 找出 bot 实际所在的群(不要猜 chat_id)
|
||||||
|
```bash
|
||||||
|
cd ~ && lark-cli im +chat-list --as bot
|
||||||
|
```
|
||||||
|
返回每个群的 `chat_id`、`name`、`description`、`owner_id`。挑出你和目标人**共处**的那个群。
|
||||||
|
- 本环境已知群:"Doro, Maggie, 魏玮"(描述"小Maggie工作群")= `oc_a927f86118216c36cb9394b0e95f2a11`。
|
||||||
|
- 若有多个候选群,按成员和描述确认,仍不确定就问用户,别赌。
|
||||||
|
|
||||||
|
### 2. @人的格式(两种已验证写法,均触发真实可点击 @ + 通知)
|
||||||
|
- **text 模式**:`--msg-type text`,content 形如 `{"text":"<at>…</at> 正文"}`,@ 标签 `<at user_id="ou_xxx">显示名</at>`(用 **open_id**;嵌进 JSON 字符串时内层引号按 JSON 规则转义)。@所有人 `<at user_id="all"></at>`。
|
||||||
|
- **post 模式**(多行/结构化汇报推荐,2026-06-16 已验证):post JSON 里 @ 用 at 元素 `{"tag":"at","user_id":"ou_xxx"}`,与文本元素 `{"tag":"text","text":"…"}` **同行**拼接,配 `--msg-type post`。结构:
|
||||||
|
`{"zh_cn":{"content":[[{"tag":"at","user_id":"ou_757f053c9d7aff6c73b18aa60c337756"},{"tag":"text","text":" 进展汇报…"}],[{"tag":"text","text":"第二行"}]]}}`
|
||||||
|
- **不要用 `--markdown`** 发 @:会强制转 post 且 @ 处理不可靠,用显式 post JSON 自己控制。
|
||||||
|
|
||||||
|
### 3. 先 dry-run 验证 payload
|
||||||
|
```bash
|
||||||
|
lark-cli im +messages-send --chat-id oc_xxx --as bot \
|
||||||
|
--content '{"text":"<at user_id=\"ou_757f053c9d7aff6c73b18aa60c337756\">Doro</at> 正文…"}' \
|
||||||
|
--msg-type text --dry-run
|
||||||
|
```
|
||||||
|
检查 body 里 chat_id、msg_type、@标签是否正确。
|
||||||
|
|
||||||
|
### 4. 去掉 --dry-run 正式发送
|
||||||
|
成功返回 `message_id` + `create_time`。把 message_id 和**北京时间**报给用户便于核对。
|
||||||
|
|
||||||
|
## 发送前如何核实「这个 open_id 确实是目标本人」
|
||||||
|
@错人是红线。理想是查通讯录,但本 bot **常缺 contact / chat 读权限**,按可靠性从高到低:
|
||||||
|
|
||||||
|
1. **gateway.log 取地面真值(最可靠,无需任何 scope)**——平台事件原始数据,比 API、比记忆都硬:
|
||||||
|
```bash
|
||||||
|
grep "oc_<群id>" ~/.hermes/logs/gateway.log | grep -oE "ou_[a-z0-9]{20,}" | sort | uniq -c | sort -rn
|
||||||
|
```
|
||||||
|
再看「当前这条消息」的入站行确认发送者:
|
||||||
|
```bash
|
||||||
|
grep "inbound message: platform=feishu" ~/.hermes/logs/gateway.log | tail
|
||||||
|
# → user=ou_xxx chat=oc_xxx msg='…' 即是谁在这个群说了这句话
|
||||||
|
```
|
||||||
|
把刚收到那句话的 `user=ou_xxx` 与已知映射比对,一致才发。
|
||||||
|
2. **已知 open_id 映射**(下方表 + memory),ID 没映射到人 → 先问不猜。
|
||||||
|
3. **通讯录 API(常被 scope 挡)**:`lark-cli contact +get-user --user-id ou_xxx --user-id-type open_id --as user`。本环境 2026-06-16 报缺 `contact:user.basic_profile:readonly`;`im chat.members get` 也缺 `im:chat.members:read` 等。缺权限是**正常状态**,别卡在这里——退回方法 1。
|
||||||
|
|
||||||
|
## 已知 open_id(核对身份用,新增/纠正后同步到 memory)
|
||||||
|
- Doro = `ou_757f053c9d7aff6c73b18aa60c337756`(与 bot 私聊 chat=`oc_9292e11bd98ddb5a69ea2c2da10d4f12`)
|
||||||
|
- Scott(魏玮) = `ou_04fade9a9335c09ad09846da2051b3c0`
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
- `--as bot`:消息以应用 bot 名义发出,bot 必须已在目标群里。
|
||||||
|
- lark-cli 是 API 工具,不是消息网关;bot 身份不代理用户(Scott 已纠正)。
|
||||||
|
- 终端里 lark-cli 输出常带 HTTPS_PROXY 的 WARN 和版本更新 notice,是噪音,不影响结果;可 `grep -v "WARN\|proxy"` 过滤。
|
||||||
|
|
||||||
|
## 发文件附件到群里(md/pdf/docx 报告,已验证)
|
||||||
|
|
||||||
|
要把一份**文件**(错误分析 md、合同 docx、报告 pdf)发到群里,用 `--file`。常见组合:**先发文件附件,再发一条 @某人 的 post 说明**(文件本身不能 @ 人,说明消息负责 @)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 先发文件(--file 接 cwd 相对路径),成功返回 message_id 并打印 "uploading file: xxx"
|
||||||
|
cd ~/lark_send_tmp && lark-cli im +messages-send \
|
||||||
|
--chat-id oc_a927f86118216c36cb9394b0e95f2a11 \
|
||||||
|
--as bot --file "./报告.md" \
|
||||||
|
--dry-run 2>&1 | grep -v "WARN\|proxy" # 先 dry-run,确认后去掉 --dry-run
|
||||||
|
# 再发 @某人 的 post 说明(见上「@人的格式」post 模式),把文件背景+要点写清楚
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚠️ 关键坑:`--file` 只接 cwd 相对路径,绝对路径被拒
|
||||||
|
lark-cli 安全限制:`--file`(及 `--image`/`--video`/`--audio`)**拒绝绝对路径**(如 `/tmp/x.md`),路径解析 `..`/symlink 后必须仍在 cwd 内。
|
||||||
|
**解法**:把文件复制到干净工作目录,`cd` 进去用 `./文件名` 发:
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/lark_send_tmp && cp "/tmp/报告.md" ~/lark_send_tmp/
|
||||||
|
cd ~/lark_send_tmp && lark-cli im +messages-send --chat-id oc_xxx --as bot --file "./报告.md"
|
||||||
|
rm -rf ~/lark_send_tmp # 发完清理
|
||||||
|
```
|
||||||
|
- 本地文件 lark-cli 会**先自动上传**再发 file 消息,无需手动 `images.create`/拿 file_key。
|
||||||
|
- dry-run 时 file_key 显示占位符 `file_dryrun_upload` 是正常的,正式发送才真上传。
|
||||||
|
- 同理发图片用 `--image ./x.png`,视频 `--video ./x.mp4 --video-cover ./cover.png`。
|
||||||
|
|
||||||
|
## 已知群与人 ID(核对身份用)
|
||||||
|
| 对象 | ID |
|
||||||
|
|------|-----|
|
||||||
|
| 飞书工作群"Doro, Maggie, 魏玮"(小Maggie工作群) | `oc_a927f86118216c36cb9394b0e95f2a11` |
|
||||||
|
| Doro | `ou_757f053c9d7aff6c73b18aa60c337756`(私聊 chat `oc_9292e11bd98ddb5a69ea2c2da10d4f12`,别和群混) |
|
||||||
|
| Scott / 魏玮 | `ou_04fade9a9335c09ad09846da2051b3c0` |
|
||||||
|
| @所有人 | `all` |
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
# 企微主动通知错投到错误的人 — 根因与诊断
|
||||||
|
|
||||||
|
**症状**:一条本应私信给 A(如 Doro)的**主动通知**,落到了 B(如 JiaQian/Maggie)的企微私信里。
|
||||||
|
实证(2026-06-17,Maggie 飞书原话):私信收到"收到 JiaQian 发来的文件…请问如何处理?"——这条内容本应发给 Doro,却投到了 JiaQian 本人。
|
||||||
|
|
||||||
|
**关键澄清:不是 userid 传错。** 会话 DB 里 `doro / JiaQian / WeiWei / ShaSha / QiuTing` 都是**各自独立的真实企微 userid**,`chat_id="doro"` 本身指向的就是 Doro。错投来自下面两个机制叠加,而非地址写错。
|
||||||
|
|
||||||
|
## 根因 1:发送者归属靠"猜"(旧 `auto_notify_new_file.sh`)
|
||||||
|
旧版 `get_sender()` 查 session DB 取"最近 5 分钟最后一个会话":
|
||||||
|
```sql
|
||||||
|
SELECT user_id FROM sessions
|
||||||
|
WHERE source='wecom' AND started_at > (now-300)
|
||||||
|
ORDER BY started_at DESC LIMIT 1
|
||||||
|
```
|
||||||
|
多人**并发**时,这个"最近活跃会话"经常不是真正发文件的人 → 发件人张冠李戴。
|
||||||
|
|
||||||
|
**已修复**:企微 adapter 落盘缓存文件时写 `.meta` 边车文件(`~/.hermes/cache/documents/<file>.meta`,字段 `sender_id / chat_id / chat_type`)。脚本改读 `${filepath}.meta`,不再靠"最近会话"猜。验证:`.meta` 内容形如 `sender_id=doro chat_id=doro type=dm`。
|
||||||
|
|
||||||
|
## 根因 2:主动私信会"退化成回复"导致串号(adapter 层)
|
||||||
|
`gateway/platforms/wecom.py` `WeComAdapter.send(chat_id, content)` 在做主动 `aibot_send_msg` 前有一段**回复优先兜底**(约 1430–1445 行):
|
||||||
|
```python
|
||||||
|
reply_req_id = self._reply_req_id_for_message(reply_to)
|
||||||
|
if not reply_req_id and chat_id in self._last_chat_req_ids:
|
||||||
|
reply_req_id = self._last_chat_req_ids[chat_id] # ← 退化点
|
||||||
|
if reply_req_id:
|
||||||
|
response = await self._send_reply_markdown(reply_req_id, content) # 回复某条历史消息,而非主动私信
|
||||||
|
else:
|
||||||
|
payload = {"chatid": chat_id, "msgtype": "markdown", ...}
|
||||||
|
if not chat_id.startswith("wr"): # 群 ID 以 "wr" 开头;非 "wr" 当私聊
|
||||||
|
payload["chat_type"] = 1 # 主动单聊
|
||||||
|
response = await self._send_request(APP_CMD_SEND, payload)
|
||||||
|
```
|
||||||
|
`_last_chat_req_ids[chat_id]` 由**入站流量**填充(`_remember_chat_req_id`,约 533 行)。在长跑的 gateway 常驻进程里、多人并发时,某个 `chat_id` key 上记住的 `req_id` 可能绑定到**归属于另一个人的会话上下文**——于是"发给 doro"退化成"回复那条 req_id",落到错误的人头上。
|
||||||
|
|
||||||
|
> 注意两个独立的 dict:`_reply_req_ids`(按 **message_id** 存,给显式 `reply_to` 用)和 `_last_chat_req_ids`(按 **chat_id** 存,作群聊无 `reply_to` 时的兜底)。错投走的是后者这条 chat_id 兜底路径。
|
||||||
|
|
||||||
|
## 谁还在踩这条路径(主动私信脚本)
|
||||||
|
任何脚本调用 `_send_wecom(extra, 'doro', msg)` → `adapter.send()` 都会经过上面的回复兜底,存在同样的串号风险。已知:
|
||||||
|
- `auto_notify_new_file.sh`:**主循环已不再调用** `notify_doro()`(非 QiuTing 文件只记日志、不通知任何人,符合信息隔离铁律;QiuTing 走静默 workflow 不发中间通知)。`notify_doro()` 函数仍**定义着**但无调用点 → 主路径安全。
|
||||||
|
- `workflow-watchdog.sh`:其 `notify()` **仍在用** `_send_wecom(extra, 'doro', msg)`,workflow 崩溃重启时会触发,**未拆除的隐患**(低频但路径有风险)。
|
||||||
|
|
||||||
|
## 修复方向(动手前须经用户确认,勿擅改代码/脚本)
|
||||||
|
- **A(最稳)**:给 adapter 加"强制主动私信"参数,让脚本类通知**绕过 `_last_chat_req_ids` 回复兜底**,永远走 `chat_type=1` proactive 直发。一次修复,所有脚本受益。涉及代码库,宜拉熟代码的人(WeiWei)评审。
|
||||||
|
- **B(最快)**:把 `workflow-watchdog.sh` 的通知目标改到本机日志/Scott,彻底不碰串号路径。改动小。
|
||||||
|
- **C(最保守)**:先出一页纸根因+修复评审文档,定了再动。
|
||||||
|
|
||||||
|
### ✅ 方向 A 已有现成实现:`~/.hermes/scripts/wecom_dm.py`(agent 可直接用)
|
||||||
|
不必等改 adapter——这个独立脚本已经实现了"强制主动私信、绕过回复兜底":自开 WebSocket,`aibot_subscribe` 认证后直接 `aibot_send_msg + chat_type=1`,**永不退化成回复**,一条消息=一次干净 proactive send、目标唯一。带 `--list` 白名单(别名↔userid 三方交叉验证)、`--dry-run`、回执 message_id。
|
||||||
|
```bash
|
||||||
|
python3 ~/.hermes/scripts/wecom_dm.py --list
|
||||||
|
python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "…" --dry-run
|
||||||
|
python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "…"
|
||||||
|
```
|
||||||
|
凡是 agent 会话里要私信某个企微人(绕开 home channel)的场景,**首选这个脚本**,而不是 `send_message(target='wecom:…')`(会静默落 home channel)或 `_send_wecom(extra,…)`(需 gateway 内部 `extra`,会话里拿不到)。`workflow-watchdog.sh` 等仍硬编码 `_send_wecom(extra,'doro',…)` 的脚本,理想改法就是切到这个干净发送器。
|
||||||
|
|
||||||
|
## 诊断配方(只读,安全)
|
||||||
|
```bash
|
||||||
|
# 1. 脚本实际发了什么、发给谁(看 chat_id 与回执)
|
||||||
|
tail -n 80 /tmp/auto_notify_new_file.log
|
||||||
|
grep -aiE "Sending response .* to|aibot_send|chat_type|私信" ~/.hermes/logs/agent.log | tail -30
|
||||||
|
|
||||||
|
# 2. 真实 userid ↔ 人 映射(确认不是地址写错)
|
||||||
|
cd ~/.hermes/hermes-agent && source venv/bin/activate
|
||||||
|
python3 -c "import sqlite3;d=sqlite3.connect('$HOME/.hermes/state.db');[print(r) for r in d.execute(\"SELECT user_id,COUNT(*),MAX(started_at) FROM sessions WHERE source='wecom' GROUP BY user_id ORDER BY 2 DESC\")]"
|
||||||
|
|
||||||
|
# 3. .meta 边车归属是否正确
|
||||||
|
find ~/.hermes/cache/documents -name '*.meta' -exec cat {} \;
|
||||||
|
|
||||||
|
# 4. 谁还在用 doro 硬编码主动发送
|
||||||
|
# search_files pattern: _send_wecom|DORO_ID="doro" 在 ~/.hermes/scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 一句话结论
|
||||||
|
"给 X 的消息错投给 Y"在企微里**首查 adapter 的 `_last_chat_req_ids` 回复兜底**与**脚本的发件人归属逻辑**,不要一上来就怀疑 userid 写错——userid 往往是对的,错在"主动私信退化成回复历史消息"。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
description: GitHub workflow skills for managing repositories, pull requests, code reviews, issues, and CI/CD pipelines using the gh CLI and git via terminal.
|
||||||
|
---
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
name: github
|
||||||
|
description: "GitHub workflow: auth setup, PR lifecycle, code review, issue management, repo operations. Covers gh CLI and curl fallbacks."
|
||||||
|
version: 1.0.0
|
||||||
|
author: Hermes Agent
|
||||||
|
license: MIT
|
||||||
|
platforms: [linux, macos, windows]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [GitHub, Git, Pull-Requests, Code-Review, Issues, Repositories, gh-cli]
|
||||||
|
---
|
||||||
|
|
||||||
|
# GitHub Workflow — Complete Guide
|
||||||
|
|
||||||
|
End-to-end GitHub operations: authentication, repository management, PR lifecycle, code review, and issue tracking. Every section shows `gh` CLI first, then `git` + `curl` fallback for machines without `gh`.
|
||||||
|
|
||||||
|
## Quick Auth Check
|
||||||
|
|
||||||
|
Run this at the start of any GitHub workflow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
if command -v gh &>/dev/null && gh auth status &>/dev/null; then
|
||||||
|
echo "AUTH_METHOD=gh"
|
||||||
|
elif [ -n "$GITHUB_TOKEN" ]; then
|
||||||
|
echo "AUTH_METHOD=curl"
|
||||||
|
elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then
|
||||||
|
export GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
|
||||||
|
echo "AUTH_METHOD=curl"
|
||||||
|
else
|
||||||
|
echo "AUTH_METHOD=none — need to set up auth first"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
See `references/auth.md` for complete auth setup (HTTPS tokens, SSH keys, gh CLI login, troubleshooting).
|
||||||
|
|
||||||
|
## Operations Overview
|
||||||
|
|
||||||
|
| Task | Reference | Key Commands |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| **Auth setup** | `references/auth.md` | `gh auth login`, SSH keys, credential helpers |
|
||||||
|
| **Repo management** | `references/repo-management.md` | clone, create, fork, releases, secrets, Actions |
|
||||||
|
| **PR lifecycle** | `references/pr-workflow.md` | branch, commit, open, CI monitoring, merge |
|
||||||
|
| **Code review** | `references/code-review.md` | local diff review, PR comments, inline review, submit review |
|
||||||
|
| **Issue management** | `references/issues.md` | create, triage, label, assign, bulk operations |
|
||||||
|
| **Codebase inspection** | `references/codebase-inspection.md` | LOC counts, language breakdown, code-vs-comment ratios via pygount |
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Creating a Feature PR (end-to-end)
|
||||||
|
1. Branch: `git checkout -b feat/description`
|
||||||
|
2. Commit with conventional format: `feat(scope): description`
|
||||||
|
3. Push: `git push -u origin HEAD`
|
||||||
|
4. Open PR: `gh pr create --title "..." --body "..."`
|
||||||
|
5. Monitor CI: `gh pr checks --watch`
|
||||||
|
6. Merge: `gh pr merge --squash --delete-branch`
|
||||||
|
|
||||||
|
See `references/pr-workflow.md` for the complete lifecycle with CI auto-fix loops.
|
||||||
|
|
||||||
|
### Reviewing a PR
|
||||||
|
1. Get PR context: `gh pr view N` + `gh pr diff N --name-only`
|
||||||
|
2. Check out locally: `git fetch origin pull/N/head:pr-N && git checkout pr-N`
|
||||||
|
3. Read diff + full files with `read_file`
|
||||||
|
4. Apply review checklist (correctness, security, quality, testing, performance)
|
||||||
|
5. Submit: `gh pr review N --approve|--request-changes|--comment`
|
||||||
|
|
||||||
|
See `references/code-review.md` for inline comments, formal reviews, and the review checklist.
|
||||||
|
|
||||||
|
### Triaging Issues
|
||||||
|
1. List untriaged: `gh issue list --label "needs-triage"`
|
||||||
|
2. Categorize and apply labels
|
||||||
|
3. Assign if owner is clear
|
||||||
|
4. Comment with triage notes
|
||||||
|
|
||||||
|
See `references/issues.md` for templates, bulk operations, and linking issues to PRs.
|
||||||
|
|
||||||
|
## Support Files
|
||||||
|
|
||||||
|
### References
|
||||||
|
- `references/auth.md` — Full authentication setup and troubleshooting
|
||||||
|
- `references/repo-management.md` — Clone, create, fork, settings, releases, secrets, Actions, gists
|
||||||
|
- `references/pr-workflow.md` — Branch creation, commits, CI monitoring, auto-fix, merging
|
||||||
|
- `references/code-review.md` — Local review, PR review, inline comments, formal review submission
|
||||||
|
- `references/issues.md` — Create, manage, triage, bulk operations
|
||||||
|
- `references/conventional-commits.md` — Commit message format conventions
|
||||||
|
- `references/ci-troubleshooting.md` — Diagnosing and fixing CI failures
|
||||||
|
- `references/review-output-template.md` — Structured review output format
|
||||||
|
- `references/github-api-cheatsheet.md` — GitHub REST API endpoint quick reference
|
||||||
|
|
||||||
|
### Templates
|
||||||
|
- `templates/bug-report.md` — Bug report issue template
|
||||||
|
- `templates/feature-request.md` — Feature request issue template
|
||||||
|
- `templates/pr-body-bugfix.md` — PR body template for bug fixes
|
||||||
|
- `templates/pr-body-feature.md` — PR body template for features
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
- `scripts/gh-env.sh` — Source this to set up GitHub auth env vars in shell
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
---
|
||||||
|
name: github-auth
|
||||||
|
description: "GitHub auth setup: HTTPS tokens, SSH keys, gh CLI login."
|
||||||
|
version: 1.1.0
|
||||||
|
author: Hermes Agent
|
||||||
|
license: MIT
|
||||||
|
platforms: [linux, macos, windows]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [GitHub, Authentication, Git, gh-cli, SSH, Setup]
|
||||||
|
related_skills: [github-pr-workflow, github-code-review, github-issues, github-repo-management]
|
||||||
|
---
|
||||||
|
|
||||||
|
# GitHub Authentication Setup
|
||||||
|
|
||||||
|
This skill sets up authentication so the agent can work with GitHub repositories, PRs, issues, and CI. It covers two paths:
|
||||||
|
|
||||||
|
- **`git` (always available)** — uses HTTPS personal access tokens or SSH keys
|
||||||
|
- **`gh` CLI (if installed)** — richer GitHub API access with a simpler auth flow
|
||||||
|
|
||||||
|
## Detection Flow
|
||||||
|
|
||||||
|
When a user asks you to work with GitHub, run this check first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check what's available
|
||||||
|
git --version
|
||||||
|
gh --version 2>/dev/null || echo "gh not installed"
|
||||||
|
|
||||||
|
# Check if already authenticated
|
||||||
|
gh auth status 2>/dev/null || echo "gh not authenticated"
|
||||||
|
git config --global credential.helper 2>/dev/null || echo "no git credential helper"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Decision tree:**
|
||||||
|
1. If `gh auth status` shows authenticated → you're good, use `gh` for everything
|
||||||
|
2. If `gh` is installed but not authenticated → use "gh auth" method below
|
||||||
|
3. If `gh` is not installed → use "git-only" method below (no sudo needed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Method 1: Git-Only Authentication (No gh, No sudo)
|
||||||
|
|
||||||
|
This works on any machine with `git` installed. No root access needed.
|
||||||
|
|
||||||
|
### Option A: HTTPS with Personal Access Token (Recommended)
|
||||||
|
|
||||||
|
This is the most portable method — works everywhere, no SSH config needed.
|
||||||
|
|
||||||
|
**Step 1: Create a personal access token**
|
||||||
|
|
||||||
|
Tell the user to go to: **https://github.com/settings/tokens**
|
||||||
|
|
||||||
|
- Click "Generate new token (classic)"
|
||||||
|
- Give it a name like "hermes-agent"
|
||||||
|
- Select scopes:
|
||||||
|
- `repo` (full repository access — read, write, push, PRs)
|
||||||
|
- `workflow` (trigger and manage GitHub Actions)
|
||||||
|
- `read:org` (if working with organization repos)
|
||||||
|
- Set expiration (90 days is a good default)
|
||||||
|
- Copy the token — it won't be shown again
|
||||||
|
|
||||||
|
**Step 2: Configure git to store the token**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set up the credential helper to cache credentials
|
||||||
|
# "store" saves to ~/.git-credentials in plaintext (simple, persistent)
|
||||||
|
git config --global credential.helper store
|
||||||
|
|
||||||
|
# Now do a test operation that triggers auth — git will prompt for credentials
|
||||||
|
# Username: <their-github-username>
|
||||||
|
# Password: <paste the personal access token, NOT their GitHub password>
|
||||||
|
git ls-remote https://github.com/<their-username>/<any-repo>.git
|
||||||
|
```
|
||||||
|
|
||||||
|
After entering credentials once, they're saved and reused for all future operations.
|
||||||
|
|
||||||
|
**Alternative: cache helper (credentials expire from memory)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cache in memory for 8 hours (28800 seconds) instead of saving to disk
|
||||||
|
git config --global credential.helper 'cache --timeout=28800'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative: set the token directly in the remote URL (per-repo)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Embed token in the remote URL (avoids credential prompts entirely)
|
||||||
|
git remote set-url origin https://<username>:<token>@github.com/<owner>/<repo>.git
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Configure git identity**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required for commits — set name and email
|
||||||
|
git config --global user.name "Their Name"
|
||||||
|
git config --global user.email "their-email@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test push access (this should work without any prompts now)
|
||||||
|
git ls-remote https://github.com/<their-username>/<any-repo>.git
|
||||||
|
|
||||||
|
# Verify identity
|
||||||
|
git config --global user.name
|
||||||
|
git config --global user.email
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: SSH Key Authentication
|
||||||
|
|
||||||
|
Good for users who prefer SSH or already have keys set up.
|
||||||
|
|
||||||
|
**Step 1: Check for existing SSH keys**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la ~/.ssh/id_*.pub 2>/dev/null || echo "No SSH keys found"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Generate a key if needed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate an ed25519 key (modern, secure, fast)
|
||||||
|
ssh-keygen -t ed25519 -C "their-email@example.com" -f ~/.ssh/id_ed25519 -N ""
|
||||||
|
|
||||||
|
# Display the public key for them to add to GitHub
|
||||||
|
cat ~/.ssh/id_ed25519.pub
|
||||||
|
```
|
||||||
|
|
||||||
|
Tell the user to add the public key at: **https://github.com/settings/keys**
|
||||||
|
- Click "New SSH key"
|
||||||
|
- Paste the public key content
|
||||||
|
- Give it a title like "hermes-agent-<machine-name>"
|
||||||
|
|
||||||
|
**Step 3: Test the connection**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -T git@github.com
|
||||||
|
# Expected: "Hi <username>! You've successfully authenticated..."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Configure git to use SSH for GitHub**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rewrite HTTPS GitHub URLs to SSH automatically
|
||||||
|
git config --global url."git@github.com:".insteadOf "https://github.com/"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Configure git identity**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config --global user.name "Their Name"
|
||||||
|
git config --global user.email "their-email@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Method 2: gh CLI Authentication
|
||||||
|
|
||||||
|
If `gh` is installed, it handles both API access and git credentials in one step.
|
||||||
|
|
||||||
|
### Interactive Browser Login (Desktop)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh auth login
|
||||||
|
# Select: GitHub.com
|
||||||
|
# Select: HTTPS
|
||||||
|
# Authenticate via browser
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token-Based Login (Headless / SSH Servers)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "<THEIR_TOKEN>" | gh auth login --with-token
|
||||||
|
|
||||||
|
# Set up git credentials through gh
|
||||||
|
gh auth setup-git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh auth status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using the GitHub API Without gh
|
||||||
|
|
||||||
|
When `gh` is not available, you can still access the full GitHub API using `curl` with a personal access token. This is how the other GitHub skills implement their fallbacks.
|
||||||
|
|
||||||
|
### Setting the Token for API Calls
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Export as env var (preferred — keeps it out of commands)
|
||||||
|
export GITHUB_TOKEN="<token>"
|
||||||
|
|
||||||
|
# Then use in curl calls:
|
||||||
|
curl -s -H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/user
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extracting the Token from Git Credentials
|
||||||
|
|
||||||
|
If git credentials are already configured (via credential.helper store), the token can be extracted:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Read from git credential store
|
||||||
|
grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Helper: Detect Auth Method
|
||||||
|
|
||||||
|
Use this pattern at the start of any GitHub workflow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Try gh first, fall back to git + curl
|
||||||
|
if command -v gh &>/dev/null && gh auth status &>/dev/null; then
|
||||||
|
echo "AUTH_METHOD=gh"
|
||||||
|
elif [ -n "$GITHUB_TOKEN" ]; then
|
||||||
|
echo "AUTH_METHOD=curl"
|
||||||
|
elif [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then
|
||||||
|
export GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r')
|
||||||
|
echo "AUTH_METHOD=curl"
|
||||||
|
elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then
|
||||||
|
export GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
|
||||||
|
echo "AUTH_METHOD=curl"
|
||||||
|
else
|
||||||
|
echo "AUTH_METHOD=none"
|
||||||
|
echo "Need to set up authentication first"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Solution |
|
||||||
|
|---------|----------|
|
||||||
|
| `git push` asks for password | GitHub disabled password auth. Use a personal access token as the password, or switch to SSH |
|
||||||
|
| `remote: Permission to X denied` | Token may lack `repo` scope — regenerate with correct scopes |
|
||||||
|
| `fatal: Authentication failed` | Cached credentials may be stale — run `git credential reject` then re-authenticate |
|
||||||
|
| `ssh: connect to host github.com port 22: Connection refused` | Try SSH over HTTPS port: add `Host github.com` with `Port 443` and `Hostname ssh.github.com` to `~/.ssh/config` |
|
||||||
|
| Credentials not persisting | Check `git config --global credential.helper` — must be `store` or `cache` |
|
||||||
|
| Multiple GitHub accounts | Use SSH with different keys per host alias in `~/.ssh/config`, or per-repo credential URLs |
|
||||||
|
| `gh: command not found` + no sudo | Use git-only Method 1 above — no installation needed |
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# CI Troubleshooting Quick Reference
|
||||||
|
|
||||||
|
Common CI failure patterns and how to diagnose them from the logs.
|
||||||
|
|
||||||
|
## Reading CI Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With gh
|
||||||
|
gh run view <RUN_ID> --log-failed
|
||||||
|
|
||||||
|
# With curl — download and extract
|
||||||
|
curl -sL -H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$GH_OWNER/$GH_REPO/actions/runs/<RUN_ID>/logs \
|
||||||
|
-o /tmp/ci-logs.zip && unzip -o /tmp/ci-logs.zip -d /tmp/ci-logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Failure Patterns
|
||||||
|
|
||||||
|
### Test Failures
|
||||||
|
|
||||||
|
**Signatures in logs:**
|
||||||
|
```
|
||||||
|
FAILED tests/test_foo.py::test_bar - AssertionError
|
||||||
|
E assert 42 == 43
|
||||||
|
ERROR tests/test_foo.py - ModuleNotFoundError
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
1. Find the test file and line number from the traceback
|
||||||
|
2. Use `read_file` to read the failing test
|
||||||
|
3. Check if it's a logic error in the code or a stale test assertion
|
||||||
|
4. Look for `ModuleNotFoundError` — usually a missing dependency in CI
|
||||||
|
|
||||||
|
**Common fixes:**
|
||||||
|
- Update assertion to match new expected behavior
|
||||||
|
- Add missing dependency to requirements.txt / pyproject.toml
|
||||||
|
- Fix flaky test (add retry, mock external service, fix race condition)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Lint / Formatting Failures
|
||||||
|
|
||||||
|
**Signatures in logs:**
|
||||||
|
```
|
||||||
|
src/auth.py:45:1: E302 expected 2 blank lines, got 1
|
||||||
|
src/models.py:12:80: E501 line too long (95 > 88 characters)
|
||||||
|
error: would reformat src/utils.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
1. Read the specific file:line numbers mentioned
|
||||||
|
2. Check which linter is complaining (flake8, ruff, black, isort, mypy)
|
||||||
|
|
||||||
|
**Common fixes:**
|
||||||
|
- Run the formatter locally: `black .`, `isort .`, `ruff check --fix .`
|
||||||
|
- Fix the specific style violation by editing the file
|
||||||
|
- If using `patch`, make sure to match existing indentation style
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Type Check Failures (mypy / pyright)
|
||||||
|
|
||||||
|
**Signatures in logs:**
|
||||||
|
```
|
||||||
|
src/api.py:23: error: Argument 1 to "process" has incompatible type "str"; expected "int"
|
||||||
|
src/models.py:45: error: Missing return statement
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
1. Read the file at the mentioned line
|
||||||
|
2. Check the function signature and what's being passed
|
||||||
|
|
||||||
|
**Common fixes:**
|
||||||
|
- Add type cast or conversion
|
||||||
|
- Fix the function signature
|
||||||
|
- Add `# type: ignore` comment as last resort (with explanation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Build / Compilation Failures
|
||||||
|
|
||||||
|
**Signatures in logs:**
|
||||||
|
```
|
||||||
|
ModuleNotFoundError: No module named 'some_package'
|
||||||
|
ERROR: Could not find a version that satisfies the requirement foo==1.2.3
|
||||||
|
npm ERR! Could not resolve dependency
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
1. Check requirements.txt / package.json for the missing or incompatible dependency
|
||||||
|
2. Compare local vs CI Python/Node version
|
||||||
|
|
||||||
|
**Common fixes:**
|
||||||
|
- Add missing dependency to requirements file
|
||||||
|
- Pin compatible version
|
||||||
|
- Update lockfile (`pip freeze`, `npm install`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Permission / Auth Failures
|
||||||
|
|
||||||
|
**Signatures in logs:**
|
||||||
|
```
|
||||||
|
fatal: could not read Username for 'https://github.com': No such device or address
|
||||||
|
Error: Resource not accessible by integration
|
||||||
|
403 Forbidden
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
1. Check if the workflow needs special permissions (token scopes)
|
||||||
|
2. Check if secrets are configured (missing `GITHUB_TOKEN` or custom secrets)
|
||||||
|
|
||||||
|
**Common fixes:**
|
||||||
|
- Add `permissions:` block to workflow YAML
|
||||||
|
- Verify secrets exist: `gh secret list` or check repo settings
|
||||||
|
- For fork PRs: some secrets aren't available by design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Timeout Failures
|
||||||
|
|
||||||
|
**Signatures in logs:**
|
||||||
|
```
|
||||||
|
Error: The operation was canceled.
|
||||||
|
The job running on runner ... has exceeded the maximum execution time
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
1. Check which step timed out
|
||||||
|
2. Look for infinite loops, hung processes, or slow network calls
|
||||||
|
|
||||||
|
**Common fixes:**
|
||||||
|
- Add timeout to the specific step: `timeout-minutes: 10`
|
||||||
|
- Fix the underlying performance issue
|
||||||
|
- Split into parallel jobs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Docker / Container Failures
|
||||||
|
|
||||||
|
**Signatures in logs:**
|
||||||
|
```
|
||||||
|
docker: Error response from daemon
|
||||||
|
failed to solve: ... not found
|
||||||
|
COPY failed: file not found in build context
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
1. Check Dockerfile for the failing step
|
||||||
|
2. Verify the referenced files exist in the repo
|
||||||
|
|
||||||
|
**Common fixes:**
|
||||||
|
- Fix path in COPY/ADD command
|
||||||
|
- Update base image tag
|
||||||
|
- Add missing file to `.dockerignore` exclusion or remove from it
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto-Fix Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
CI Failed
|
||||||
|
├── Test failure
|
||||||
|
│ ├── Assertion mismatch → update test or fix logic
|
||||||
|
│ └── Import/module error → add dependency
|
||||||
|
├── Lint failure → run formatter, fix style
|
||||||
|
├── Type error → fix types
|
||||||
|
├── Build failure
|
||||||
|
│ ├── Missing dep → add to requirements
|
||||||
|
│ └── Version conflict → update pins
|
||||||
|
├── Permission error → update workflow permissions (needs user)
|
||||||
|
└── Timeout → investigate perf (may need user input)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Re-running After Fix
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add <fixed_files> && git commit -m "fix: resolve CI failure" && git push
|
||||||
|
|
||||||
|
# Then monitor
|
||||||
|
gh pr checks --watch 2>/dev/null || \
|
||||||
|
echo "Poll with: curl -s -H 'Authorization: token ...' https://api.github.com/repos/.../commits/$(git rev-parse HEAD)/status"
|
||||||
|
```
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
---
|
||||||
|
name: github-code-review
|
||||||
|
description: "Review PRs: diffs, inline comments via gh or REST."
|
||||||
|
version: 1.1.0
|
||||||
|
author: Hermes Agent
|
||||||
|
license: MIT
|
||||||
|
platforms: [linux, macos, windows]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [GitHub, Code-Review, Pull-Requests, Git, Quality]
|
||||||
|
related_skills: [github-auth, github-pr-workflow]
|
||||||
|
---
|
||||||
|
|
||||||
|
# GitHub Code Review
|
||||||
|
|
||||||
|
Perform code reviews on local changes before pushing, or review open PRs on GitHub. Most of this skill uses plain `git` — the `gh`/`curl` split only matters for PR-level interactions.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Authenticated with GitHub (see `github-auth` skill)
|
||||||
|
- Inside a git repository
|
||||||
|
|
||||||
|
### Setup (for PR interactions)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
if command -v gh &>/dev/null && gh auth status &>/dev/null; then
|
||||||
|
AUTH="gh"
|
||||||
|
else
|
||||||
|
AUTH="git"
|
||||||
|
if [ -z "$GITHUB_TOKEN" ]; then
|
||||||
|
if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then
|
||||||
|
GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r')
|
||||||
|
elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then
|
||||||
|
GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
REMOTE_URL=$(git remote get-url origin)
|
||||||
|
OWNER_REPO=$(echo "$REMOTE_URL" | sed -E 's|.*github\.com[:/]||; s|\.git$||')
|
||||||
|
OWNER=$(echo "$OWNER_REPO" | cut -d/ -f1)
|
||||||
|
REPO=$(echo "$OWNER_REPO" | cut -d/ -f2)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Reviewing Local Changes (Pre-Push)
|
||||||
|
|
||||||
|
This is pure `git` — works everywhere, no API needed.
|
||||||
|
|
||||||
|
### Get the Diff
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Staged changes (what would be committed)
|
||||||
|
git diff --staged
|
||||||
|
|
||||||
|
# All changes vs main (what a PR would contain)
|
||||||
|
git diff main...HEAD
|
||||||
|
|
||||||
|
# File names only
|
||||||
|
git diff main...HEAD --name-only
|
||||||
|
|
||||||
|
# Stat summary (insertions/deletions per file)
|
||||||
|
git diff main...HEAD --stat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Review Strategy
|
||||||
|
|
||||||
|
1. **Get the big picture first:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff main...HEAD --stat
|
||||||
|
git log main..HEAD --oneline
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Review file by file** — use `read_file` on changed files for full context, and the diff to see what changed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff main...HEAD -- src/auth/login.py
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Check for common issues:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debug statements, TODOs, console.logs left behind
|
||||||
|
git diff main...HEAD | grep -n "print(\|console\.log\|TODO\|FIXME\|HACK\|XXX\|debugger"
|
||||||
|
|
||||||
|
# Large files accidentally staged
|
||||||
|
git diff main...HEAD --stat | sort -t'|' -k2 -rn | head -10
|
||||||
|
|
||||||
|
# Secrets or credential patterns
|
||||||
|
git diff main...HEAD | grep -in "password\|secret\|api_key\|token.*=\|private_key"
|
||||||
|
|
||||||
|
# Merge conflict markers
|
||||||
|
git diff main...HEAD | grep -n "<<<<<<\|>>>>>>\|======="
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Present structured feedback** to the user.
|
||||||
|
|
||||||
|
### Review Output Format
|
||||||
|
|
||||||
|
When reviewing local changes, present findings in this structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Code Review Summary
|
||||||
|
|
||||||
|
### Critical
|
||||||
|
- **src/auth.py:45** — SQL injection: user input passed directly to query.
|
||||||
|
Suggestion: Use parameterized queries.
|
||||||
|
|
||||||
|
### Warnings
|
||||||
|
- **src/models/user.py:23** — Password stored in plaintext. Use bcrypt or argon2.
|
||||||
|
- **src/api/routes.py:112** — No rate limiting on login endpoint.
|
||||||
|
|
||||||
|
### Suggestions
|
||||||
|
- **src/utils/helpers.py:8** — Duplicates logic in `src/core/utils.py:34`. Consolidate.
|
||||||
|
- **tests/test_auth.py** — Missing edge case: expired token test.
|
||||||
|
|
||||||
|
### Looks Good
|
||||||
|
- Clean separation of concerns in the middleware layer
|
||||||
|
- Good test coverage for the happy path
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Reviewing a Pull Request on GitHub
|
||||||
|
|
||||||
|
### View PR Details
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr view 123
|
||||||
|
gh pr diff 123
|
||||||
|
gh pr diff 123 --name-only
|
||||||
|
```
|
||||||
|
|
||||||
|
**With git + curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PR_NUMBER=123
|
||||||
|
|
||||||
|
# Get PR details
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
pr = json.load(sys.stdin)
|
||||||
|
print(f\"Title: {pr['title']}\")
|
||||||
|
print(f\"Author: {pr['user']['login']}\")
|
||||||
|
print(f\"Branch: {pr['head']['ref']} -> {pr['base']['ref']}\")
|
||||||
|
print(f\"State: {pr['state']}\")
|
||||||
|
print(f\"Body:\n{pr['body']}\")"
|
||||||
|
|
||||||
|
# List changed files
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER/files \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
for f in json.load(sys.stdin):
|
||||||
|
print(f\"{f['status']:10} +{f['additions']:-4} -{f['deletions']:-4} {f['filename']}\")"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Out PR Locally for Full Review
|
||||||
|
|
||||||
|
This works with plain `git` — no `gh` needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Fetch the PR branch and check it out
|
||||||
|
git fetch origin pull/123/head:pr-123
|
||||||
|
git checkout pr-123
|
||||||
|
|
||||||
|
# Now you can use read_file, search_files, run tests, etc.
|
||||||
|
|
||||||
|
# View diff against the base branch
|
||||||
|
git diff main...pr-123
|
||||||
|
```
|
||||||
|
|
||||||
|
**With gh (shortcut):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr checkout 123
|
||||||
|
```
|
||||||
|
|
||||||
|
### Leave Comments on a PR
|
||||||
|
|
||||||
|
**General PR comment — with gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr comment 123 --body "Overall looks good, a few suggestions below."
|
||||||
|
```
|
||||||
|
|
||||||
|
**General PR comment — with curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/issues/$PR_NUMBER/comments \
|
||||||
|
-d '{"body": "Overall looks good, a few suggestions below."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Leave Inline Review Comments
|
||||||
|
|
||||||
|
**Single inline comment — with gh (via API):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HEAD_SHA=$(gh pr view 123 --json headRefOid --jq '.headRefOid')
|
||||||
|
|
||||||
|
gh api repos/$OWNER/$REPO/pulls/123/comments \
|
||||||
|
--method POST \
|
||||||
|
-f body="This could be simplified with a list comprehension." \
|
||||||
|
-f path="src/auth/login.py" \
|
||||||
|
-f commit_id="$HEAD_SHA" \
|
||||||
|
-f line=45 \
|
||||||
|
-f side="RIGHT"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Single inline comment — with curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get the head commit SHA
|
||||||
|
HEAD_SHA=$(curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
|
||||||
|
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER/comments \
|
||||||
|
-d "{
|
||||||
|
\"body\": \"This could be simplified with a list comprehension.\",
|
||||||
|
\"path\": \"src/auth/login.py\",
|
||||||
|
\"commit_id\": \"$HEAD_SHA\",
|
||||||
|
\"line\": 45,
|
||||||
|
\"side\": \"RIGHT\"
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Submit a Formal Review (Approve / Request Changes)
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr review 123 --approve --body "LGTM!"
|
||||||
|
gh pr review 123 --request-changes --body "See inline comments."
|
||||||
|
gh pr review 123 --comment --body "Some suggestions, nothing blocking."
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl — multi-comment review submitted atomically:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HEAD_SHA=$(curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
|
||||||
|
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER/reviews \
|
||||||
|
-d "{
|
||||||
|
\"commit_id\": \"$HEAD_SHA\",
|
||||||
|
\"event\": \"COMMENT\",
|
||||||
|
\"body\": \"Code review from Hermes Agent\",
|
||||||
|
\"comments\": [
|
||||||
|
{\"path\": \"src/auth.py\", \"line\": 45, \"body\": \"Use parameterized queries to prevent SQL injection.\"},
|
||||||
|
{\"path\": \"src/models/user.py\", \"line\": 23, \"body\": \"Hash passwords with bcrypt before storing.\"},
|
||||||
|
{\"path\": \"tests/test_auth.py\", \"line\": 1, \"body\": \"Add test for expired token edge case.\"}
|
||||||
|
]
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Event values: `"APPROVE"`, `"REQUEST_CHANGES"`, `"COMMENT"`
|
||||||
|
|
||||||
|
The `line` field refers to the line number in the *new* version of the file. For deleted lines, use `"side": "LEFT"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Review Checklist
|
||||||
|
|
||||||
|
When performing a code review (local or PR), systematically check:
|
||||||
|
|
||||||
|
### Correctness
|
||||||
|
- Does the code do what it claims?
|
||||||
|
- Edge cases handled (empty inputs, nulls, large data, concurrent access)?
|
||||||
|
- Error paths handled gracefully?
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- No hardcoded secrets, credentials, or API keys
|
||||||
|
- Input validation on user-facing inputs
|
||||||
|
- No SQL injection, XSS, or path traversal
|
||||||
|
- Auth/authz checks where needed
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- Clear naming (variables, functions, classes)
|
||||||
|
- No unnecessary complexity or premature abstraction
|
||||||
|
- DRY — no duplicated logic that should be extracted
|
||||||
|
- Functions are focused (single responsibility)
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- New code paths tested?
|
||||||
|
- Happy path and error cases covered?
|
||||||
|
- Tests readable and maintainable?
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- No N+1 queries or unnecessary loops
|
||||||
|
- Appropriate caching where beneficial
|
||||||
|
- No blocking operations in async code paths
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Public APIs documented
|
||||||
|
- Non-obvious logic has comments explaining "why"
|
||||||
|
- README updated if behavior changed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Pre-Push Review Workflow
|
||||||
|
|
||||||
|
When the user asks you to "review the code" or "check before pushing":
|
||||||
|
|
||||||
|
1. `git diff main...HEAD --stat` — see scope of changes
|
||||||
|
2. `git diff main...HEAD` — read the full diff
|
||||||
|
3. For each changed file, use `read_file` if you need more context
|
||||||
|
4. Apply the checklist above
|
||||||
|
5. Present findings in the structured format (Critical / Warnings / Suggestions / Looks Good)
|
||||||
|
6. If critical issues found, offer to fix them before the user pushes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. PR Review Workflow (End-to-End)
|
||||||
|
|
||||||
|
When the user asks you to "review PR #N", "look at this PR", or gives you a PR URL, follow this recipe:
|
||||||
|
|
||||||
|
### Step 1: Set up environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/gh-env.sh"
|
||||||
|
# Or run the inline setup block from the top of this skill
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Gather PR context
|
||||||
|
|
||||||
|
Get the PR metadata, description, and list of changed files to understand scope before diving into code.
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
```bash
|
||||||
|
gh pr view 123
|
||||||
|
gh pr diff 123 --name-only
|
||||||
|
gh pr checks 123
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl:**
|
||||||
|
```bash
|
||||||
|
PR_NUMBER=123
|
||||||
|
|
||||||
|
# PR details (title, author, description, branch)
|
||||||
|
curl -s -H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER
|
||||||
|
|
||||||
|
# Changed files with line counts
|
||||||
|
curl -s -H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER/files
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Check out the PR locally
|
||||||
|
|
||||||
|
This gives you full access to `read_file`, `search_files`, and the ability to run tests.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin pull/$PR_NUMBER/head:pr-$PR_NUMBER
|
||||||
|
git checkout pr-$PR_NUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Read the diff and understand changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full diff against the base branch
|
||||||
|
git diff main...HEAD
|
||||||
|
|
||||||
|
# Or file-by-file for large PRs
|
||||||
|
git diff main...HEAD --name-only
|
||||||
|
# Then for each file:
|
||||||
|
git diff main...HEAD -- path/to/file.py
|
||||||
|
```
|
||||||
|
|
||||||
|
For each changed file, use `read_file` to see full context around the changes — diffs alone can miss issues visible only with surrounding code.
|
||||||
|
|
||||||
|
### Step 5: Run automated checks locally (if applicable)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests if there's a test suite
|
||||||
|
python -m pytest 2>&1 | tail -20
|
||||||
|
# or: npm test, cargo test, go test ./..., etc.
|
||||||
|
|
||||||
|
# Run linter if configured
|
||||||
|
ruff check . 2>&1 | head -30
|
||||||
|
# or: eslint, clippy, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Apply the review checklist (Section 3)
|
||||||
|
|
||||||
|
Go through each category: Correctness, Security, Code Quality, Testing, Performance, Documentation.
|
||||||
|
|
||||||
|
### Step 7: Post the review to GitHub
|
||||||
|
|
||||||
|
Collect your findings and submit them as a formal review with inline comments.
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
```bash
|
||||||
|
# If no issues — approve
|
||||||
|
gh pr review $PR_NUMBER --approve --body "Reviewed by Hermes Agent. Code looks clean — good test coverage, no security concerns."
|
||||||
|
|
||||||
|
# If issues found — request changes with inline comments
|
||||||
|
gh pr review $PR_NUMBER --request-changes --body "Found a few issues — see inline comments."
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl — atomic review with multiple inline comments:**
|
||||||
|
```bash
|
||||||
|
HEAD_SHA=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
|
||||||
|
|
||||||
|
# Build the review JSON — event is APPROVE, REQUEST_CHANGES, or COMMENT
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$GH_OWNER/$GH_REPO/pulls/$PR_NUMBER/reviews \
|
||||||
|
-d "{
|
||||||
|
\"commit_id\": \"$HEAD_SHA\",
|
||||||
|
\"event\": \"REQUEST_CHANGES\",
|
||||||
|
\"body\": \"## Hermes Agent Review\n\nFound 2 issues, 1 suggestion. See inline comments.\",
|
||||||
|
\"comments\": [
|
||||||
|
{\"path\": \"src/auth.py\", \"line\": 45, \"body\": \"🔴 **Critical:** User input passed directly to SQL query — use parameterized queries.\"},
|
||||||
|
{\"path\": \"src/models.py\", \"line\": 23, \"body\": \"⚠️ **Warning:** Password stored without hashing.\"},
|
||||||
|
{\"path\": \"src/utils.py\", \"line\": 8, \"body\": \"💡 **Suggestion:** This duplicates logic in core/utils.py:34.\"}
|
||||||
|
]
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: Also post a summary comment
|
||||||
|
|
||||||
|
In addition to inline comments, leave a top-level summary so the PR author gets the full picture at a glance. Use the review output format from `references/review-output-template.md`.
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
```bash
|
||||||
|
gh pr comment $PR_NUMBER --body "$(cat <<'EOF'
|
||||||
|
## Code Review Summary
|
||||||
|
|
||||||
|
**Verdict: Changes Requested** (2 issues, 1 suggestion)
|
||||||
|
|
||||||
|
### 🔴 Critical
|
||||||
|
- **src/auth.py:45** — SQL injection vulnerability
|
||||||
|
|
||||||
|
### ⚠️ Warnings
|
||||||
|
- **src/models.py:23** — Plaintext password storage
|
||||||
|
|
||||||
|
### 💡 Suggestions
|
||||||
|
- **src/utils.py:8** — Duplicated logic, consider consolidating
|
||||||
|
|
||||||
|
### ✅ Looks Good
|
||||||
|
- Clean API design
|
||||||
|
- Good error handling in the middleware layer
|
||||||
|
|
||||||
|
---
|
||||||
|
*Reviewed by Hermes Agent*
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 9: Clean up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout main
|
||||||
|
git branch -D pr-$PR_NUMBER
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decision: Approve vs Request Changes vs Comment
|
||||||
|
|
||||||
|
- **Approve** — no critical or warning-level issues, only minor suggestions or all clear
|
||||||
|
- **Request Changes** — any critical or warning-level issue that should be fixed before merge
|
||||||
|
- **Comment** — observations and suggestions, but nothing blocking (use when you're unsure or the PR is a draft)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
name: codebase-inspection
|
||||||
|
description: "Inspect codebases w/ pygount: LOC, languages, ratios."
|
||||||
|
version: 1.0.0
|
||||||
|
author: Hermes Agent
|
||||||
|
license: MIT
|
||||||
|
platforms: [linux, macos, windows]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [LOC, Code Analysis, pygount, Codebase, Metrics, Repository]
|
||||||
|
related_skills: [github-repo-management]
|
||||||
|
prerequisites:
|
||||||
|
commands: [pygount]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Codebase Inspection with pygount
|
||||||
|
|
||||||
|
Analyze repositories for lines of code, language breakdown, file counts, and code-vs-comment ratios using `pygount`.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- User asks for LOC (lines of code) count
|
||||||
|
- User wants a language breakdown of a repo
|
||||||
|
- User asks about codebase size or composition
|
||||||
|
- User wants code-vs-comment ratios
|
||||||
|
- General "how big is this repo" questions
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install --break-system-packages pygount 2>/dev/null || pip install pygount
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Basic Summary (Most Common)
|
||||||
|
|
||||||
|
Get a full language breakdown with file counts, code lines, and comment lines:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/repo
|
||||||
|
pygount --format=summary \
|
||||||
|
--folders-to-skip=".git,node_modules,venv,.venv,__pycache__,.cache,dist,build,.next,.tox,.eggs,*.egg-info" \
|
||||||
|
.
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT:** Always use `--folders-to-skip` to exclude dependency/build directories, otherwise pygount will crawl them and take a very long time or hang.
|
||||||
|
|
||||||
|
## 2. Common Folder Exclusions
|
||||||
|
|
||||||
|
Adjust based on the project type:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python projects
|
||||||
|
--folders-to-skip=".git,venv,.venv,__pycache__,.cache,dist,build,.tox,.eggs,.mypy_cache"
|
||||||
|
|
||||||
|
# JavaScript/TypeScript projects
|
||||||
|
--folders-to-skip=".git,node_modules,dist,build,.next,.cache,.turbo,coverage"
|
||||||
|
|
||||||
|
# General catch-all
|
||||||
|
--folders-to-skip=".git,node_modules,venv,.venv,__pycache__,.cache,dist,build,.next,.tox,vendor,third_party"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Filter by Specific Language
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Only count Python files
|
||||||
|
pygount --suffix=py --format=summary .
|
||||||
|
|
||||||
|
# Only count Python and YAML
|
||||||
|
pygount --suffix=py,yaml,yml --format=summary .
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Detailed File-by-File Output
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Default format shows per-file breakdown
|
||||||
|
pygount --folders-to-skip=".git,node_modules,venv" .
|
||||||
|
|
||||||
|
# Sort by code lines (pipe through sort)
|
||||||
|
pygount --folders-to-skip=".git,node_modules,venv" . | sort -t$'\t' -k1 -nr | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Output Formats
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Summary table (default recommendation)
|
||||||
|
pygount --format=summary .
|
||||||
|
|
||||||
|
# JSON output for programmatic use
|
||||||
|
pygount --format=json .
|
||||||
|
|
||||||
|
# Pipe-friendly: Language, file count, code, docs, empty, string
|
||||||
|
pygount --format=summary . 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Interpreting Results
|
||||||
|
|
||||||
|
The summary table columns:
|
||||||
|
- **Language** — detected programming language
|
||||||
|
- **Files** — number of files of that language
|
||||||
|
- **Code** — lines of actual code (executable/declarative)
|
||||||
|
- **Comment** — lines that are comments or documentation
|
||||||
|
- **%** — percentage of total
|
||||||
|
|
||||||
|
Special pseudo-languages:
|
||||||
|
- `__empty__` — empty files
|
||||||
|
- `__binary__` — binary files (images, compiled, etc.)
|
||||||
|
- `__generated__` — auto-generated files (detected heuristically)
|
||||||
|
- `__duplicate__` — files with identical content
|
||||||
|
- `__unknown__` — unrecognized file types
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
|
||||||
|
1. **Always exclude .git, node_modules, venv** — without `--folders-to-skip`, pygount will crawl everything and may take minutes or hang on large dependency trees.
|
||||||
|
2. **Markdown shows 0 code lines** — pygount classifies all Markdown content as comments, not code. This is expected behavior.
|
||||||
|
3. **JSON files show low code counts** — pygount may count JSON lines conservatively. For accurate JSON line counts, use `wc -l` directly.
|
||||||
|
4. **Large monorepos** — for very large repos, consider using `--suffix` to target specific languages rather than scanning everything.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Conventional Commits Quick Reference
|
||||||
|
|
||||||
|
Format: `type(scope): description`
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
| Type | When to use | Example |
|
||||||
|
|------|------------|---------|
|
||||||
|
| `feat` | New feature or capability | `feat(auth): add OAuth2 login flow` |
|
||||||
|
| `fix` | Bug fix | `fix(api): handle null response from /users endpoint` |
|
||||||
|
| `refactor` | Code restructuring, no behavior change | `refactor(db): extract query builder into separate module` |
|
||||||
|
| `docs` | Documentation only | `docs: update API usage examples in README` |
|
||||||
|
| `test` | Adding or updating tests | `test(auth): add integration tests for token refresh` |
|
||||||
|
| `ci` | CI/CD configuration | `ci: add Python 3.12 to test matrix` |
|
||||||
|
| `chore` | Maintenance, dependencies, tooling | `chore: upgrade pytest to 8.x` |
|
||||||
|
| `perf` | Performance improvement | `perf(search): add index on users.email column` |
|
||||||
|
| `style` | Formatting, whitespace, semicolons | `style: run black formatter on src/` |
|
||||||
|
| `build` | Build system or external deps | `build: switch from setuptools to hatch` |
|
||||||
|
| `revert` | Reverts a previous commit | `revert: revert "feat(auth): add OAuth2 login flow"` |
|
||||||
|
|
||||||
|
## Scope (optional)
|
||||||
|
|
||||||
|
Short identifier for the area of the codebase: `auth`, `api`, `db`, `ui`, `cli`, etc.
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
Add `!` after type or `BREAKING CHANGE:` in footer:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(api)!: change authentication to use bearer tokens
|
||||||
|
|
||||||
|
BREAKING CHANGE: API endpoints now require Bearer token instead of API key header.
|
||||||
|
Migration guide: https://docs.example.com/migrate-auth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-line Body
|
||||||
|
|
||||||
|
Wrap at 72 characters. Use bullet points for multiple changes:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(auth): add JWT-based user authentication
|
||||||
|
|
||||||
|
- Add login/register endpoints with input validation
|
||||||
|
- Add User model with argon2 password hashing
|
||||||
|
- Add auth middleware for protected routes
|
||||||
|
- Add token refresh endpoint with rotation
|
||||||
|
|
||||||
|
Closes #42
|
||||||
|
```
|
||||||
|
|
||||||
|
## Linking Issues
|
||||||
|
|
||||||
|
In the commit body or footer:
|
||||||
|
|
||||||
|
```
|
||||||
|
Closes #42 ← closes the issue when merged
|
||||||
|
Fixes #42 ← same effect
|
||||||
|
Refs #42 ← references without closing
|
||||||
|
Co-authored-by: Name <email>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Decision Guide
|
||||||
|
|
||||||
|
- Added something new? → `feat`
|
||||||
|
- Something was broken and you fixed it? → `fix`
|
||||||
|
- Changed how code is organized but not what it does? → `refactor`
|
||||||
|
- Only touched tests? → `test`
|
||||||
|
- Only touched docs? → `docs`
|
||||||
|
- Updated CI/CD pipelines? → `ci`
|
||||||
|
- Updated dependencies or tooling? → `chore`
|
||||||
|
- Made something faster? → `perf`
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# GitHub REST API Cheatsheet
|
||||||
|
|
||||||
|
Base URL: `https://api.github.com`
|
||||||
|
|
||||||
|
All requests need: `-H "Authorization: token $GITHUB_TOKEN"`
|
||||||
|
|
||||||
|
Use the `gh-env.sh` helper to set `$GITHUB_TOKEN`, `$GH_OWNER`, `$GH_REPO` automatically:
|
||||||
|
```bash
|
||||||
|
source "${HERMES_HOME:-$HOME/.hermes}/skills/github/github-auth/scripts/gh-env.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repositories
|
||||||
|
|
||||||
|
| Action | Method | Endpoint |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| Get repo info | GET | `/repos/{owner}/{repo}` |
|
||||||
|
| Create repo (user) | POST | `/user/repos` |
|
||||||
|
| Create repo (org) | POST | `/orgs/{org}/repos` |
|
||||||
|
| Update repo | PATCH | `/repos/{owner}/{repo}` |
|
||||||
|
| Delete repo | DELETE | `/repos/{owner}/{repo}` |
|
||||||
|
| List your repos | GET | `/user/repos?per_page=30&sort=updated` |
|
||||||
|
| List org repos | GET | `/orgs/{org}/repos` |
|
||||||
|
| Fork repo | POST | `/repos/{owner}/{repo}/forks` |
|
||||||
|
| Create from template | POST | `/repos/{owner}/{template}/generate` |
|
||||||
|
| Get topics | GET | `/repos/{owner}/{repo}/topics` |
|
||||||
|
| Set topics | PUT | `/repos/{owner}/{repo}/topics` |
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
| Action | Method | Endpoint |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| List PRs | GET | `/repos/{owner}/{repo}/pulls?state=open` |
|
||||||
|
| Create PR | POST | `/repos/{owner}/{repo}/pulls` |
|
||||||
|
| Get PR | GET | `/repos/{owner}/{repo}/pulls/{number}` |
|
||||||
|
| Update PR | PATCH | `/repos/{owner}/{repo}/pulls/{number}` |
|
||||||
|
| List PR files | GET | `/repos/{owner}/{repo}/pulls/{number}/files` |
|
||||||
|
| Merge PR | PUT | `/repos/{owner}/{repo}/pulls/{number}/merge` |
|
||||||
|
| Request reviewers | POST | `/repos/{owner}/{repo}/pulls/{number}/requested_reviewers` |
|
||||||
|
| Create review | POST | `/repos/{owner}/{repo}/pulls/{number}/reviews` |
|
||||||
|
| Inline comment | POST | `/repos/{owner}/{repo}/pulls/{number}/comments` |
|
||||||
|
|
||||||
|
### PR Merge Body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"merge_method": "squash", "commit_title": "feat: description (#N)"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Merge methods: `"merge"`, `"squash"`, `"rebase"`
|
||||||
|
|
||||||
|
### PR Review Events
|
||||||
|
|
||||||
|
`"APPROVE"`, `"REQUEST_CHANGES"`, `"COMMENT"`
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
| Action | Method | Endpoint |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| List issues | GET | `/repos/{owner}/{repo}/issues?state=open` |
|
||||||
|
| Create issue | POST | `/repos/{owner}/{repo}/issues` |
|
||||||
|
| Get issue | GET | `/repos/{owner}/{repo}/issues/{number}` |
|
||||||
|
| Update issue | PATCH | `/repos/{owner}/{repo}/issues/{number}` |
|
||||||
|
| Add comment | POST | `/repos/{owner}/{repo}/issues/{number}/comments` |
|
||||||
|
| Add labels | POST | `/repos/{owner}/{repo}/issues/{number}/labels` |
|
||||||
|
| Remove label | DELETE | `/repos/{owner}/{repo}/issues/{number}/labels/{name}` |
|
||||||
|
| Add assignees | POST | `/repos/{owner}/{repo}/issues/{number}/assignees` |
|
||||||
|
| List labels | GET | `/repos/{owner}/{repo}/labels` |
|
||||||
|
| Search issues | GET | `/search/issues?q={query}+repo:{owner}/{repo}` |
|
||||||
|
|
||||||
|
Note: The Issues API also returns PRs. Filter with `"pull_request" not in item` when parsing.
|
||||||
|
|
||||||
|
## CI / GitHub Actions
|
||||||
|
|
||||||
|
| Action | Method | Endpoint |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| List workflows | GET | `/repos/{owner}/{repo}/actions/workflows` |
|
||||||
|
| List runs | GET | `/repos/{owner}/{repo}/actions/runs?per_page=10` |
|
||||||
|
| List runs (branch) | GET | `/repos/{owner}/{repo}/actions/runs?branch={branch}` |
|
||||||
|
| Get run | GET | `/repos/{owner}/{repo}/actions/runs/{run_id}` |
|
||||||
|
| Download logs | GET | `/repos/{owner}/{repo}/actions/runs/{run_id}/logs` |
|
||||||
|
| Re-run | POST | `/repos/{owner}/{repo}/actions/runs/{run_id}/rerun` |
|
||||||
|
| Re-run failed | POST | `/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs` |
|
||||||
|
| Trigger dispatch | POST | `/repos/{owner}/{repo}/actions/workflows/{id}/dispatches` |
|
||||||
|
| Commit status | GET | `/repos/{owner}/{repo}/commits/{sha}/status` |
|
||||||
|
| Check runs | GET | `/repos/{owner}/{repo}/commits/{sha}/check-runs` |
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
| Action | Method | Endpoint |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| List releases | GET | `/repos/{owner}/{repo}/releases` |
|
||||||
|
| Create release | POST | `/repos/{owner}/{repo}/releases` |
|
||||||
|
| Get release | GET | `/repos/{owner}/{repo}/releases/{id}` |
|
||||||
|
| Delete release | DELETE | `/repos/{owner}/{repo}/releases/{id}` |
|
||||||
|
| Upload asset | POST | `https://uploads.github.com/repos/{owner}/{repo}/releases/{id}/assets?name={filename}` |
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
| Action | Method | Endpoint |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| List secrets | GET | `/repos/{owner}/{repo}/actions/secrets` |
|
||||||
|
| Get public key | GET | `/repos/{owner}/{repo}/actions/secrets/public-key` |
|
||||||
|
| Set secret | PUT | `/repos/{owner}/{repo}/actions/secrets/{name}` |
|
||||||
|
| Delete secret | DELETE | `/repos/{owner}/{repo}/actions/secrets/{name}` |
|
||||||
|
|
||||||
|
## Branch Protection
|
||||||
|
|
||||||
|
| Action | Method | Endpoint |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| Get protection | GET | `/repos/{owner}/{repo}/branches/{branch}/protection` |
|
||||||
|
| Set protection | PUT | `/repos/{owner}/{repo}/branches/{branch}/protection` |
|
||||||
|
| Delete protection | DELETE | `/repos/{owner}/{repo}/branches/{branch}/protection` |
|
||||||
|
|
||||||
|
## User / Auth
|
||||||
|
|
||||||
|
| Action | Method | Endpoint |
|
||||||
|
|--------|--------|----------|
|
||||||
|
| Get current user | GET | `/user` |
|
||||||
|
| List user repos | GET | `/user/repos` |
|
||||||
|
| List user gists | GET | `/gists` |
|
||||||
|
| Create gist | POST | `/gists` |
|
||||||
|
| Search repos | GET | `/search/repositories?q={query}` |
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
Most list endpoints support:
|
||||||
|
- `?per_page=100` (max 100)
|
||||||
|
- `?page=2` for next page
|
||||||
|
- Check `Link` header for `rel="next"` URL
|
||||||
|
|
||||||
|
## Rate Limits
|
||||||
|
|
||||||
|
- Authenticated: 5,000 requests/hour
|
||||||
|
- Check remaining: `curl -s -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/rate_limit`
|
||||||
|
|
||||||
|
## Common curl Patterns
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# GET
|
||||||
|
curl -s -H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$GH_OWNER/$GH_REPO
|
||||||
|
|
||||||
|
# POST with JSON body
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$GH_OWNER/$GH_REPO/issues \
|
||||||
|
-d '{"title": "...", "body": "..."}'
|
||||||
|
|
||||||
|
# PATCH (update)
|
||||||
|
curl -s -X PATCH \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$GH_OWNER/$GH_REPO/issues/42 \
|
||||||
|
-d '{"state": "closed"}'
|
||||||
|
|
||||||
|
# DELETE
|
||||||
|
curl -s -X DELETE \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$GH_OWNER/$GH_REPO/issues/42/labels/bug
|
||||||
|
|
||||||
|
# Parse JSON response with python3
|
||||||
|
curl -s ... | python3 -c "import sys,json; data=json.load(sys.stdin); print(data['field'])"
|
||||||
|
```
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
---
|
||||||
|
name: github-issues
|
||||||
|
description: "Create, triage, label, assign GitHub issues via gh or REST."
|
||||||
|
version: 1.1.0
|
||||||
|
author: Hermes Agent
|
||||||
|
license: MIT
|
||||||
|
platforms: [linux, macos, windows]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [GitHub, Issues, Project-Management, Bug-Tracking, Triage]
|
||||||
|
related_skills: [github-auth, github-pr-workflow]
|
||||||
|
---
|
||||||
|
|
||||||
|
# GitHub Issues Management
|
||||||
|
|
||||||
|
Create, search, triage, and manage GitHub issues. Each section shows `gh` first, then the `curl` fallback.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Authenticated with GitHub (see `github-auth` skill)
|
||||||
|
- Inside a git repo with a GitHub remote, or specify the repo explicitly
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
if command -v gh &>/dev/null && gh auth status &>/dev/null; then
|
||||||
|
AUTH="gh"
|
||||||
|
else
|
||||||
|
AUTH="git"
|
||||||
|
if [ -z "$GITHUB_TOKEN" ]; then
|
||||||
|
if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then
|
||||||
|
GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r')
|
||||||
|
elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then
|
||||||
|
GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
REMOTE_URL=$(git remote get-url origin)
|
||||||
|
OWNER_REPO=$(echo "$REMOTE_URL" | sed -E 's|.*github\.com[:/]||; s|\.git$||')
|
||||||
|
OWNER=$(echo "$OWNER_REPO" | cut -d/ -f1)
|
||||||
|
REPO=$(echo "$OWNER_REPO" | cut -d/ -f2)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Viewing Issues
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh issue list
|
||||||
|
gh issue list --state open --label "bug"
|
||||||
|
gh issue list --assignee @me
|
||||||
|
gh issue list --search "authentication error" --state all
|
||||||
|
gh issue view 42
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List open issues
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/$OWNER/$REPO/issues?state=open&per_page=20" \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
for i in json.load(sys.stdin):
|
||||||
|
if 'pull_request' not in i: # GitHub API returns PRs in /issues too
|
||||||
|
labels = ', '.join(l['name'] for l in i['labels'])
|
||||||
|
print(f\"#{i['number']:5} {i['state']:6} {labels:30} {i['title']}\")"
|
||||||
|
|
||||||
|
# Filter by label
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/$OWNER/$REPO/issues?state=open&labels=bug&per_page=20" \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
for i in json.load(sys.stdin):
|
||||||
|
if 'pull_request' not in i:
|
||||||
|
print(f\"#{i['number']} {i['title']}\")"
|
||||||
|
|
||||||
|
# View a specific issue
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/issues/42 \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
i = json.load(sys.stdin)
|
||||||
|
labels = ', '.join(l['name'] for l in i['labels'])
|
||||||
|
assignees = ', '.join(a['login'] for a in i['assignees'])
|
||||||
|
print(f\"#{i['number']}: {i['title']}\")
|
||||||
|
print(f\"State: {i['state']} Labels: {labels} Assignees: {assignees}\")
|
||||||
|
print(f\"Author: {i['user']['login']} Created: {i['created_at']}\")
|
||||||
|
print(f\"\n{i['body']}\")"
|
||||||
|
|
||||||
|
# Search issues
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/search/issues?q=authentication+error+repo:$OWNER/$REPO" \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
for i in json.load(sys.stdin)['items']:
|
||||||
|
print(f\"#{i['number']} {i['state']:6} {i['title']}\")"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Creating Issues
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh issue create \
|
||||||
|
--title "Login redirect ignores ?next= parameter" \
|
||||||
|
--body "## Description
|
||||||
|
After logging in, users always land on /dashboard.
|
||||||
|
|
||||||
|
## Steps to Reproduce
|
||||||
|
1. Navigate to /settings while logged out
|
||||||
|
2. Get redirected to /login?next=/settings
|
||||||
|
3. Log in
|
||||||
|
4. Actual: redirected to /dashboard (should go to /settings)
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
Respect the ?next= query parameter." \
|
||||||
|
--label "bug,backend" \
|
||||||
|
--assignee "username"
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/issues \
|
||||||
|
-d '{
|
||||||
|
"title": "Login redirect ignores ?next= parameter",
|
||||||
|
"body": "## Description\nAfter logging in, users always land on /dashboard.\n\n## Steps to Reproduce\n1. Navigate to /settings while logged out\n2. Get redirected to /login?next=/settings\n3. Log in\n4. Actual: redirected to /dashboard\n\n## Expected Behavior\nRespect the ?next= query parameter.",
|
||||||
|
"labels": ["bug", "backend"],
|
||||||
|
"assignees": ["username"]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bug Report Template
|
||||||
|
|
||||||
|
```
|
||||||
|
## Bug Description
|
||||||
|
<What's happening>
|
||||||
|
|
||||||
|
## Steps to Reproduce
|
||||||
|
1. <step>
|
||||||
|
2. <step>
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
<What should happen>
|
||||||
|
|
||||||
|
## Actual Behavior
|
||||||
|
<What actually happens>
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
- OS: <os>
|
||||||
|
- Version: <version>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature Request Template
|
||||||
|
|
||||||
|
```
|
||||||
|
## Feature Description
|
||||||
|
<What you want>
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
<Why this would be useful>
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
<How it could work>
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
<Other approaches>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Managing Issues
|
||||||
|
|
||||||
|
### Add/Remove Labels
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh issue edit 42 --add-label "priority:high,bug"
|
||||||
|
gh issue edit 42 --remove-label "needs-triage"
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add labels
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/issues/42/labels \
|
||||||
|
-d '{"labels": ["priority:high", "bug"]}'
|
||||||
|
|
||||||
|
# Remove a label
|
||||||
|
curl -s -X DELETE \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/issues/42/labels/needs-triage
|
||||||
|
|
||||||
|
# List available labels in the repo
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/labels \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
for l in json.load(sys.stdin):
|
||||||
|
print(f\" {l['name']:30} {l.get('description', '')}\")"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Assignment
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh issue edit 42 --add-assignee username
|
||||||
|
gh issue edit 42 --add-assignee @me
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/issues/42/assignees \
|
||||||
|
-d '{"assignees": ["username"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commenting
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh issue comment 42 --body "Investigated — root cause is in auth middleware. Working on a fix."
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/issues/42/comments \
|
||||||
|
-d '{"body": "Investigated — root cause is in auth middleware. Working on a fix."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Closing and Reopening
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh issue close 42
|
||||||
|
gh issue close 42 --reason "not planned"
|
||||||
|
gh issue reopen 42
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Close
|
||||||
|
curl -s -X PATCH \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/issues/42 \
|
||||||
|
-d '{"state": "closed", "state_reason": "completed"}'
|
||||||
|
|
||||||
|
# Reopen
|
||||||
|
curl -s -X PATCH \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/issues/42 \
|
||||||
|
-d '{"state": "open"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linking Issues to PRs
|
||||||
|
|
||||||
|
Issues are automatically closed when a PR merges with the right keywords in the body:
|
||||||
|
|
||||||
|
```
|
||||||
|
Closes #42
|
||||||
|
Fixes #42
|
||||||
|
Resolves #42
|
||||||
|
```
|
||||||
|
|
||||||
|
To create a branch from an issue:
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh issue develop 42 --checkout
|
||||||
|
```
|
||||||
|
|
||||||
|
**With git (manual equivalent):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout main && git pull origin main
|
||||||
|
git checkout -b fix/issue-42-login-redirect
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Issue Triage Workflow
|
||||||
|
|
||||||
|
When asked to triage issues:
|
||||||
|
|
||||||
|
1. **List untriaged issues:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With gh
|
||||||
|
gh issue list --label "needs-triage" --state open
|
||||||
|
|
||||||
|
# With curl
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/$OWNER/$REPO/issues?labels=needs-triage&state=open" \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
for i in json.load(sys.stdin):
|
||||||
|
if 'pull_request' not in i:
|
||||||
|
print(f\"#{i['number']} {i['title']}\")"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Read and categorize** each issue (view details, understand the bug/feature)
|
||||||
|
|
||||||
|
3. **Apply labels and priority** (see Managing Issues above)
|
||||||
|
|
||||||
|
4. **Assign** if the owner is clear
|
||||||
|
|
||||||
|
5. **Comment with triage notes** if needed
|
||||||
|
|
||||||
|
## 5. Bulk Operations
|
||||||
|
|
||||||
|
For batch operations, combine API calls with shell scripting:
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Close all issues with a specific label
|
||||||
|
gh issue list --label "wontfix" --json number --jq '.[].number' | \
|
||||||
|
xargs -I {} gh issue close {} --reason "not planned"
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List issue numbers with a label, then close each
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/$OWNER/$REPO/issues?labels=wontfix&state=open" \
|
||||||
|
| python3 -c "import sys,json; [print(i['number']) for i in json.load(sys.stdin)]" \
|
||||||
|
| while read num; do
|
||||||
|
curl -s -X PATCH \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/issues/$num \
|
||||||
|
-d '{"state": "closed", "state_reason": "not_planned"}'
|
||||||
|
echo "Closed #$num"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference Table
|
||||||
|
|
||||||
|
| Action | gh | curl endpoint |
|
||||||
|
|--------|-----|--------------|
|
||||||
|
| List issues | `gh issue list` | `GET /repos/{o}/{r}/issues` |
|
||||||
|
| View issue | `gh issue view N` | `GET /repos/{o}/{r}/issues/N` |
|
||||||
|
| Create issue | `gh issue create ...` | `POST /repos/{o}/{r}/issues` |
|
||||||
|
| Add labels | `gh issue edit N --add-label ...` | `POST /repos/{o}/{r}/issues/N/labels` |
|
||||||
|
| Assign | `gh issue edit N --add-assignee ...` | `POST /repos/{o}/{r}/issues/N/assignees` |
|
||||||
|
| Comment | `gh issue comment N --body ...` | `POST /repos/{o}/{r}/issues/N/comments` |
|
||||||
|
| Close | `gh issue close N` | `PATCH /repos/{o}/{r}/issues/N` |
|
||||||
|
| Search | `gh issue list --search "..."` | `GET /search/issues?q=...` |
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
---
|
||||||
|
name: github-pr-workflow
|
||||||
|
description: "GitHub PR lifecycle: branch, commit, open, CI, merge."
|
||||||
|
version: 1.1.0
|
||||||
|
author: Hermes Agent
|
||||||
|
license: MIT
|
||||||
|
platforms: [linux, macos, windows]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [GitHub, Pull-Requests, CI/CD, Git, Automation, Merge]
|
||||||
|
related_skills: [github-auth, github-code-review]
|
||||||
|
---
|
||||||
|
|
||||||
|
# GitHub Pull Request Workflow
|
||||||
|
|
||||||
|
Complete guide for managing the PR lifecycle. Each section shows the `gh` way first, then the `git` + `curl` fallback for machines without `gh`.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Authenticated with GitHub (see `github-auth` skill)
|
||||||
|
- Inside a git repository with a GitHub remote
|
||||||
|
|
||||||
|
### Quick Auth Detection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Determine which method to use throughout this workflow
|
||||||
|
if command -v gh &>/dev/null && gh auth status &>/dev/null; then
|
||||||
|
AUTH="gh"
|
||||||
|
else
|
||||||
|
AUTH="git"
|
||||||
|
# Ensure we have a token for API calls
|
||||||
|
if [ -z "$GITHUB_TOKEN" ]; then
|
||||||
|
if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then
|
||||||
|
GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r')
|
||||||
|
elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then
|
||||||
|
GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "Using: $AUTH"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extracting Owner/Repo from the Git Remote
|
||||||
|
|
||||||
|
Many `curl` commands need `owner/repo`. Extract it from the git remote:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Works for both HTTPS and SSH remote URLs
|
||||||
|
REMOTE_URL=$(git remote get-url origin)
|
||||||
|
OWNER_REPO=$(echo "$REMOTE_URL" | sed -E 's|.*github\.com[:/]||; s|\.git$||')
|
||||||
|
OWNER=$(echo "$OWNER_REPO" | cut -d/ -f1)
|
||||||
|
REPO=$(echo "$OWNER_REPO" | cut -d/ -f2)
|
||||||
|
echo "Owner: $OWNER, Repo: $REPO"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Branch Creation
|
||||||
|
|
||||||
|
This part is pure `git` — identical either way:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make sure you're up to date
|
||||||
|
git fetch origin
|
||||||
|
git checkout main && git pull origin main
|
||||||
|
|
||||||
|
# Create and switch to a new branch
|
||||||
|
git checkout -b feat/add-user-authentication
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch naming conventions:
|
||||||
|
- `feat/description` — new features
|
||||||
|
- `fix/description` — bug fixes
|
||||||
|
- `refactor/description` — code restructuring
|
||||||
|
- `docs/description` — documentation
|
||||||
|
- `ci/description` — CI/CD changes
|
||||||
|
|
||||||
|
## 2. Making Commits
|
||||||
|
|
||||||
|
Use the agent's file tools (`write_file`, `patch`) to make changes, then commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stage specific files
|
||||||
|
git add src/auth.py src/models/user.py tests/test_auth.py
|
||||||
|
|
||||||
|
# Commit with a conventional commit message
|
||||||
|
git commit -m "feat: add JWT-based user authentication
|
||||||
|
|
||||||
|
- Add login/register endpoints
|
||||||
|
- Add User model with password hashing
|
||||||
|
- Add auth middleware for protected routes
|
||||||
|
- Add unit tests for auth flow"
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit message format (Conventional Commits):
|
||||||
|
```
|
||||||
|
type(scope): short description
|
||||||
|
|
||||||
|
Longer explanation if needed. Wrap at 72 characters.
|
||||||
|
```
|
||||||
|
|
||||||
|
Types: `feat`, `fix`, `refactor`, `docs`, `test`, `ci`, `chore`, `perf`
|
||||||
|
|
||||||
|
## 3. Pushing and Creating a PR
|
||||||
|
|
||||||
|
### Push the Branch (same either way)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create the PR
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr create \
|
||||||
|
--title "feat: add JWT-based user authentication" \
|
||||||
|
--body "## Summary
|
||||||
|
- Adds login and register API endpoints
|
||||||
|
- JWT token generation and validation
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
- [ ] Unit tests pass
|
||||||
|
|
||||||
|
Closes #42"
|
||||||
|
```
|
||||||
|
|
||||||
|
Options: `--draft`, `--reviewer user1,user2`, `--label "enhancement"`, `--base develop`
|
||||||
|
|
||||||
|
**With git + curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BRANCH=$(git branch --show-current)
|
||||||
|
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
-H "Accept: application/vnd.github.v3+json" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/pulls \
|
||||||
|
-d "{
|
||||||
|
\"title\": \"feat: add JWT-based user authentication\",
|
||||||
|
\"body\": \"## Summary\nAdds login and register API endpoints.\n\nCloses #42\",
|
||||||
|
\"head\": \"$BRANCH\",
|
||||||
|
\"base\": \"main\"
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
The response JSON includes the PR `number` — save it for later commands.
|
||||||
|
|
||||||
|
To create as a draft, add `"draft": true` to the JSON body.
|
||||||
|
|
||||||
|
## 4. Monitoring CI Status
|
||||||
|
|
||||||
|
### Check CI Status
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One-shot check
|
||||||
|
gh pr checks
|
||||||
|
|
||||||
|
# Watch until all checks finish (polls every 10s)
|
||||||
|
gh pr checks --watch
|
||||||
|
```
|
||||||
|
|
||||||
|
**With git + curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get the latest commit SHA on the current branch
|
||||||
|
SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
# Query the combined status
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/commits/$SHA/status \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
print(f\"Overall: {data['state']}\")
|
||||||
|
for s in data.get('statuses', []):
|
||||||
|
print(f\" {s['context']}: {s['state']} - {s.get('description', '')}\")"
|
||||||
|
|
||||||
|
# Also check GitHub Actions check runs (separate endpoint)
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/commits/$SHA/check-runs \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
for cr in data.get('check_runs', []):
|
||||||
|
print(f\" {cr['name']}: {cr['status']} / {cr['conclusion'] or 'pending'}\")"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Poll Until Complete (git + curl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Simple polling loop — check every 30 seconds, up to 10 minutes
|
||||||
|
SHA=$(git rev-parse HEAD)
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
STATUS=$(curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/commits/$SHA/status \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['state'])")
|
||||||
|
echo "Check $i: $STATUS"
|
||||||
|
if [ "$STATUS" = "success" ] || [ "$STATUS" = "failure" ] || [ "$STATUS" = "error" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 30
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Auto-Fixing CI Failures
|
||||||
|
|
||||||
|
When CI fails, diagnose and fix. This loop works with either auth method.
|
||||||
|
|
||||||
|
### Step 1: Get Failure Details
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List recent workflow runs on this branch
|
||||||
|
gh run list --branch $(git branch --show-current) --limit 5
|
||||||
|
|
||||||
|
# View failed logs
|
||||||
|
gh run view <RUN_ID> --log-failed
|
||||||
|
```
|
||||||
|
|
||||||
|
**With git + curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BRANCH=$(git branch --show-current)
|
||||||
|
|
||||||
|
# List workflow runs on this branch
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/$OWNER/$REPO/actions/runs?branch=$BRANCH&per_page=5" \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
runs = json.load(sys.stdin)['workflow_runs']
|
||||||
|
for r in runs:
|
||||||
|
print(f\"Run {r['id']}: {r['name']} - {r['conclusion'] or r['status']}\")"
|
||||||
|
|
||||||
|
# Get failed job logs (download as zip, extract, read)
|
||||||
|
RUN_ID=<run_id>
|
||||||
|
curl -s -L \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/actions/runs/$RUN_ID/logs \
|
||||||
|
-o /tmp/ci-logs.zip
|
||||||
|
cd /tmp && unzip -o ci-logs.zip -d ci-logs && cat ci-logs/*.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Fix and Push
|
||||||
|
|
||||||
|
After identifying the issue, use file tools (`patch`, `write_file`) to fix it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add <fixed_files>
|
||||||
|
git commit -m "fix: resolve CI failure in <check_name>"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Verify
|
||||||
|
|
||||||
|
Re-check CI status using the commands from Section 4 above.
|
||||||
|
|
||||||
|
### Auto-Fix Loop Pattern
|
||||||
|
|
||||||
|
When asked to auto-fix CI, follow this loop:
|
||||||
|
|
||||||
|
1. Check CI status → identify failures
|
||||||
|
2. Read failure logs → understand the error
|
||||||
|
3. Use `read_file` + `patch`/`write_file` → fix the code
|
||||||
|
4. `git add . && git commit -m "fix: ..." && git push`
|
||||||
|
5. Wait for CI → re-check status
|
||||||
|
6. Repeat if still failing (up to 3 attempts, then ask the user)
|
||||||
|
|
||||||
|
## 6. Merging
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Squash merge + delete branch (cleanest for feature branches)
|
||||||
|
gh pr merge --squash --delete-branch
|
||||||
|
|
||||||
|
# Enable auto-merge (merges when all checks pass)
|
||||||
|
gh pr merge --auto --squash --delete-branch
|
||||||
|
```
|
||||||
|
|
||||||
|
**With git + curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PR_NUMBER=<number>
|
||||||
|
|
||||||
|
# Merge the PR via API (squash)
|
||||||
|
curl -s -X PUT \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER/merge \
|
||||||
|
-d "{
|
||||||
|
\"merge_method\": \"squash\",
|
||||||
|
\"commit_title\": \"feat: add user authentication (#$PR_NUMBER)\"
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Delete the remote branch after merge
|
||||||
|
BRANCH=$(git branch --show-current)
|
||||||
|
git push origin --delete $BRANCH
|
||||||
|
|
||||||
|
# Switch back to main locally
|
||||||
|
git checkout main && git pull origin main
|
||||||
|
git branch -d $BRANCH
|
||||||
|
```
|
||||||
|
|
||||||
|
Merge methods: `"merge"` (merge commit), `"squash"`, `"rebase"`
|
||||||
|
|
||||||
|
### Enable Auto-Merge (curl)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auto-merge requires the repo to have it enabled in settings.
|
||||||
|
# This uses the GraphQL API since REST doesn't support auto-merge.
|
||||||
|
PR_NODE_ID=$(curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/pulls/$PR_NUMBER \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['node_id'])")
|
||||||
|
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/graphql \
|
||||||
|
-d "{\"query\": \"mutation { enablePullRequestAutoMerge(input: {pullRequestId: \\\"$PR_NODE_ID\\\", mergeMethod: SQUASH}) { clientMutationId } }\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Complete Workflow Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start from clean main
|
||||||
|
git checkout main && git pull origin main
|
||||||
|
|
||||||
|
# 2. Branch
|
||||||
|
git checkout -b fix/login-redirect-bug
|
||||||
|
|
||||||
|
# 3. (Agent makes code changes with file tools)
|
||||||
|
|
||||||
|
# 4. Commit
|
||||||
|
git add src/auth/login.py tests/test_login.py
|
||||||
|
git commit -m "fix: correct redirect URL after login
|
||||||
|
|
||||||
|
Preserves the ?next= parameter instead of always redirecting to /dashboard."
|
||||||
|
|
||||||
|
# 5. Push
|
||||||
|
git push -u origin HEAD
|
||||||
|
|
||||||
|
# 6. Create PR (picks gh or curl based on what's available)
|
||||||
|
# ... (see Section 3)
|
||||||
|
|
||||||
|
# 7. Monitor CI (see Section 4)
|
||||||
|
|
||||||
|
# 8. Merge when green (see Section 6)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Useful PR Commands Reference
|
||||||
|
|
||||||
|
| Action | gh | git + curl |
|
||||||
|
|--------|-----|-----------|
|
||||||
|
| List my PRs | `gh pr list --author @me` | `curl -s -H "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/$OWNER/$REPO/pulls?state=open"` |
|
||||||
|
| View PR diff | `gh pr diff` | `git diff main...HEAD` (local) or `curl -H "Accept: application/vnd.github.diff" ...` |
|
||||||
|
| Add comment | `gh pr comment N --body "..."` | `curl -X POST .../issues/N/comments -d '{"body":"..."}'` |
|
||||||
|
| Request review | `gh pr edit N --add-reviewer user` | `curl -X POST .../pulls/N/requested_reviewers -d '{"reviewers":["user"]}'` |
|
||||||
|
| Close PR | `gh pr close N` | `curl -X PATCH .../pulls/N -d '{"state":"closed"}'` |
|
||||||
|
| Check out someone's PR | `gh pr checkout N` | `git fetch origin pull/N/head:pr-N && git checkout pr-N` |
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
---
|
||||||
|
name: github-repo-management
|
||||||
|
description: "Clone/create/fork repos; manage remotes, releases."
|
||||||
|
version: 1.1.0
|
||||||
|
author: Hermes Agent
|
||||||
|
license: MIT
|
||||||
|
platforms: [linux, macos, windows]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [GitHub, Repositories, Git, Releases, Secrets, Configuration]
|
||||||
|
related_skills: [github-auth, github-pr-workflow, github-issues]
|
||||||
|
---
|
||||||
|
|
||||||
|
# GitHub Repository Management
|
||||||
|
|
||||||
|
Create, clone, fork, configure, and manage GitHub repositories. Each section shows `gh` first, then the `git` + `curl` fallback.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Authenticated with GitHub (see `github-auth` skill)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
if command -v gh &>/dev/null && gh auth status &>/dev/null; then
|
||||||
|
AUTH="gh"
|
||||||
|
else
|
||||||
|
AUTH="git"
|
||||||
|
if [ -z "$GITHUB_TOKEN" ]; then
|
||||||
|
if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then
|
||||||
|
GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r')
|
||||||
|
elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then
|
||||||
|
GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get your GitHub username (needed for several operations)
|
||||||
|
if [ "$AUTH" = "gh" ]; then
|
||||||
|
GH_USER=$(gh api user --jq '.login')
|
||||||
|
else
|
||||||
|
GH_USER=$(curl -s -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/user | python3 -c "import sys,json; print(json.load(sys.stdin)['login'])")
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
If you're inside a repo already:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REMOTE_URL=$(git remote get-url origin)
|
||||||
|
OWNER_REPO=$(echo "$REMOTE_URL" | sed -E 's|.*github\.com[:/]||; s|\.git$||')
|
||||||
|
OWNER=$(echo "$OWNER_REPO" | cut -d/ -f1)
|
||||||
|
REPO=$(echo "$OWNER_REPO" | cut -d/ -f2)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Cloning Repositories
|
||||||
|
|
||||||
|
Cloning is pure `git` — works identically either way:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone via HTTPS (works with credential helper or token-embedded URL)
|
||||||
|
git clone https://github.com/owner/repo-name.git
|
||||||
|
|
||||||
|
# Clone into a specific directory
|
||||||
|
git clone https://github.com/owner/repo-name.git ./my-local-dir
|
||||||
|
|
||||||
|
# Shallow clone (faster for large repos)
|
||||||
|
git clone --depth 1 https://github.com/owner/repo-name.git
|
||||||
|
|
||||||
|
# Clone a specific branch
|
||||||
|
git clone --branch develop https://github.com/owner/repo-name.git
|
||||||
|
|
||||||
|
# Clone via SSH (if SSH is configured)
|
||||||
|
git clone git@github.com:owner/repo-name.git
|
||||||
|
```
|
||||||
|
|
||||||
|
**With gh (shorthand):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh repo clone owner/repo-name
|
||||||
|
gh repo clone owner/repo-name -- --depth 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Creating Repositories
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a public repo and clone it
|
||||||
|
gh repo create my-new-project --public --clone
|
||||||
|
|
||||||
|
# Private, with description and license
|
||||||
|
gh repo create my-new-project --private --description "A useful tool" --license MIT --clone
|
||||||
|
|
||||||
|
# Under an organization
|
||||||
|
gh repo create my-org/my-new-project --public --clone
|
||||||
|
|
||||||
|
# From existing local directory
|
||||||
|
cd /path/to/existing/project
|
||||||
|
gh repo create my-project --source . --public --push
|
||||||
|
```
|
||||||
|
|
||||||
|
**With git + curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create the remote repo via API
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/user/repos \
|
||||||
|
-d '{
|
||||||
|
"name": "my-new-project",
|
||||||
|
"description": "A useful tool",
|
||||||
|
"private": false,
|
||||||
|
"auto_init": true,
|
||||||
|
"license_template": "mit"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Clone it
|
||||||
|
git clone https://github.com/$GH_USER/my-new-project.git
|
||||||
|
cd my-new-project
|
||||||
|
|
||||||
|
# -- OR -- push an existing local directory to the new repo
|
||||||
|
cd /path/to/existing/project
|
||||||
|
git init
|
||||||
|
git add .
|
||||||
|
git commit -m "Initial commit"
|
||||||
|
git remote add origin https://github.com/$GH_USER/my-new-project.git
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
To create under an organization:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/orgs/my-org/repos \
|
||||||
|
-d '{"name": "my-new-project", "private": false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### From a Template
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh repo create my-new-app --template owner/template-repo --public --clone
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/owner/template-repo/generate \
|
||||||
|
-d '{"owner": "'"$GH_USER"'", "name": "my-new-app", "private": false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Forking Repositories
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh repo fork owner/repo-name --clone
|
||||||
|
```
|
||||||
|
|
||||||
|
**With git + curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create the fork via API
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/owner/repo-name/forks
|
||||||
|
|
||||||
|
# Wait a moment for GitHub to create it, then clone
|
||||||
|
sleep 3
|
||||||
|
git clone https://github.com/$GH_USER/repo-name.git
|
||||||
|
cd repo-name
|
||||||
|
|
||||||
|
# Add the original repo as "upstream" remote
|
||||||
|
git remote add upstream https://github.com/owner/repo-name.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keeping a Fork in Sync
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pure git — works everywhere
|
||||||
|
git fetch upstream
|
||||||
|
git checkout main
|
||||||
|
git merge upstream/main
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
**With gh (shortcut):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh repo sync $GH_USER/repo-name
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Repository Information
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh repo view owner/repo-name
|
||||||
|
gh repo list --limit 20
|
||||||
|
gh search repos "machine learning" --language python --sort stars
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View repo details
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
r = json.load(sys.stdin)
|
||||||
|
print(f\"Name: {r['full_name']}\")
|
||||||
|
print(f\"Description: {r['description']}\")
|
||||||
|
print(f\"Stars: {r['stargazers_count']} Forks: {r['forks_count']}\")
|
||||||
|
print(f\"Default branch: {r['default_branch']}\")
|
||||||
|
print(f\"Language: {r['language']}\")"
|
||||||
|
|
||||||
|
# List your repos
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/user/repos?per_page=20&sort=updated" \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
for r in json.load(sys.stdin):
|
||||||
|
vis = 'private' if r['private'] else 'public'
|
||||||
|
print(f\" {r['full_name']:40} {vis:8} {r.get('language', ''):10} ★{r['stargazers_count']}\")"
|
||||||
|
|
||||||
|
# Search repos
|
||||||
|
curl -s \
|
||||||
|
"https://api.github.com/search/repositories?q=machine+learning+language:python&sort=stars&per_page=10" \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
for r in json.load(sys.stdin)['items']:
|
||||||
|
print(f\" {r['full_name']:40} ★{r['stargazers_count']:6} {r['description'][:60] if r['description'] else ''}\")"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Repository Settings
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh repo edit --description "Updated description" --visibility public
|
||||||
|
gh repo edit --enable-wiki=false --enable-issues=true
|
||||||
|
gh repo edit --default-branch main
|
||||||
|
gh repo edit --add-topic "machine-learning,python"
|
||||||
|
gh repo edit --enable-auto-merge
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PATCH \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO \
|
||||||
|
-d '{
|
||||||
|
"description": "Updated description",
|
||||||
|
"has_wiki": false,
|
||||||
|
"has_issues": true,
|
||||||
|
"allow_auto_merge": true
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Update topics
|
||||||
|
curl -s -X PUT \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
-H "Accept: application/vnd.github.mercy-preview+json" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/topics \
|
||||||
|
-d '{"names": ["machine-learning", "python", "automation"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Branch Protection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View current protection
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/branches/main/protection
|
||||||
|
|
||||||
|
# Set up branch protection
|
||||||
|
curl -s -X PUT \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/branches/main/protection \
|
||||||
|
-d '{
|
||||||
|
"required_status_checks": {
|
||||||
|
"strict": true,
|
||||||
|
"contexts": ["ci/test", "ci/lint"]
|
||||||
|
},
|
||||||
|
"enforce_admins": false,
|
||||||
|
"required_pull_request_reviews": {
|
||||||
|
"required_approving_review_count": 1
|
||||||
|
},
|
||||||
|
"restrictions": null
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Secrets Management (GitHub Actions)
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh secret set API_KEY --body "your-secret-value"
|
||||||
|
gh secret set SSH_KEY < ~/.ssh/id_rsa
|
||||||
|
gh secret list
|
||||||
|
gh secret delete API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl:**
|
||||||
|
|
||||||
|
Secrets require encryption with the repo's public key — more involved via API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get the repo's public key for encrypting secrets
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/actions/secrets/public-key
|
||||||
|
|
||||||
|
# Encrypt and set (requires Python with PyNaCl)
|
||||||
|
python3 -c "
|
||||||
|
from base64 import b64encode
|
||||||
|
from nacl import encoding, public
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
# Get the public key
|
||||||
|
key_id = '<key_id_from_above>'
|
||||||
|
public_key = '<base64_key_from_above>'
|
||||||
|
|
||||||
|
# Encrypt
|
||||||
|
sealed = public.SealedBox(
|
||||||
|
public.PublicKey(public_key.encode('utf-8'), encoding.Base64Encoder)
|
||||||
|
).encrypt('your-secret-value'.encode('utf-8'))
|
||||||
|
print(json.dumps({
|
||||||
|
'encrypted_value': b64encode(sealed).decode('utf-8'),
|
||||||
|
'key_id': key_id
|
||||||
|
}))"
|
||||||
|
|
||||||
|
# Then PUT the encrypted secret
|
||||||
|
curl -s -X PUT \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/actions/secrets/API_KEY \
|
||||||
|
-d '<output from python script above>'
|
||||||
|
|
||||||
|
# List secrets (names only, values hidden)
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/actions/secrets \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
for s in json.load(sys.stdin)['secrets']:
|
||||||
|
print(f\" {s['name']:30} updated: {s['updated_at']}\")"
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: For secrets, `gh secret set` is dramatically simpler. If setting secrets is needed and `gh` isn't available, recommend installing it for just that operation.
|
||||||
|
|
||||||
|
## 8. Releases
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh release create v1.0.0 --title "v1.0.0" --generate-notes
|
||||||
|
gh release create v2.0.0-rc1 --draft --prerelease --generate-notes
|
||||||
|
gh release create v1.0.0 ./dist/binary --title "v1.0.0" --notes "Release notes"
|
||||||
|
gh release list
|
||||||
|
gh release download v1.0.0 --dir ./downloads
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a release
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/releases \
|
||||||
|
-d '{
|
||||||
|
"tag_name": "v1.0.0",
|
||||||
|
"name": "v1.0.0",
|
||||||
|
"body": "## Changelog\n- Feature A\n- Bug fix B",
|
||||||
|
"draft": false,
|
||||||
|
"prerelease": false,
|
||||||
|
"generate_release_notes": true
|
||||||
|
}'
|
||||||
|
|
||||||
|
# List releases
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/releases \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
for r in json.load(sys.stdin):
|
||||||
|
tag = r.get('tag_name', 'no tag')
|
||||||
|
print(f\" {tag:15} {r['name']:30} {'draft' if r['draft'] else 'published'}\")"
|
||||||
|
|
||||||
|
# Upload a release asset (binary file)
|
||||||
|
RELEASE_ID=<id_from_create_response>
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
"https://uploads.github.com/repos/$OWNER/$REPO/releases/$RELEASE_ID/assets?name=binary-amd64" \
|
||||||
|
--data-binary @./dist/binary-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. GitHub Actions Workflows
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh workflow list
|
||||||
|
gh run list --limit 10
|
||||||
|
gh run view <RUN_ID>
|
||||||
|
gh run view <RUN_ID> --log-failed
|
||||||
|
gh run rerun <RUN_ID>
|
||||||
|
gh run rerun <RUN_ID> --failed
|
||||||
|
gh workflow run ci.yml --ref main
|
||||||
|
gh workflow run deploy.yml -f environment=staging
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List workflows
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/actions/workflows \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
for w in json.load(sys.stdin)['workflows']:
|
||||||
|
print(f\" {w['id']:10} {w['name']:30} {w['state']}\")"
|
||||||
|
|
||||||
|
# List recent runs
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
"https://api.github.com/repos/$OWNER/$REPO/actions/runs?per_page=10" \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
for r in json.load(sys.stdin)['workflow_runs']:
|
||||||
|
print(f\" Run {r['id']} {r['name']:30} {r['conclusion'] or r['status']}\")"
|
||||||
|
|
||||||
|
# Download failed run logs
|
||||||
|
RUN_ID=<run_id>
|
||||||
|
curl -s -L \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/actions/runs/$RUN_ID/logs \
|
||||||
|
-o /tmp/ci-logs.zip
|
||||||
|
cd /tmp && unzip -o ci-logs.zip -d ci-logs
|
||||||
|
|
||||||
|
# Re-run a failed workflow
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/actions/runs/$RUN_ID/rerun
|
||||||
|
|
||||||
|
# Re-run only failed jobs
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/actions/runs/$RUN_ID/rerun-failed-jobs
|
||||||
|
|
||||||
|
# Trigger a workflow manually (workflow_dispatch)
|
||||||
|
WORKFLOW_ID=<workflow_id_or_filename>
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/repos/$OWNER/$REPO/actions/workflows/$WORKFLOW_ID/dispatches \
|
||||||
|
-d '{"ref": "main", "inputs": {"environment": "staging"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Gists
|
||||||
|
|
||||||
|
**With gh:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh gist create script.py --public --desc "Useful script"
|
||||||
|
gh gist list
|
||||||
|
```
|
||||||
|
|
||||||
|
**With curl:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a gist
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/gists \
|
||||||
|
-d '{
|
||||||
|
"description": "Useful script",
|
||||||
|
"public": true,
|
||||||
|
"files": {
|
||||||
|
"script.py": {"content": "print(\"hello\")"}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
|
||||||
|
# List your gists
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/gists \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
for g in json.load(sys.stdin):
|
||||||
|
files = ', '.join(g['files'].keys())
|
||||||
|
print(f\" {g['id']} {g['description'] or '(no desc)':40} {files}\")"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference Table
|
||||||
|
|
||||||
|
| Action | gh | git + curl |
|
||||||
|
|--------|-----|-----------|
|
||||||
|
| Clone | `gh repo clone o/r` | `git clone https://github.com/o/r.git` |
|
||||||
|
| Create repo | `gh repo create name --public` | `curl POST /user/repos` |
|
||||||
|
| Fork | `gh repo fork o/r --clone` | `curl POST /repos/o/r/forks` + `git clone` |
|
||||||
|
| Repo info | `gh repo view o/r` | `curl GET /repos/o/r` |
|
||||||
|
| Edit settings | `gh repo edit --...` | `curl PATCH /repos/o/r` |
|
||||||
|
| Create release | `gh release create v1.0` | `curl POST /repos/o/r/releases` |
|
||||||
|
| List workflows | `gh workflow list` | `curl GET /repos/o/r/actions/workflows` |
|
||||||
|
| Rerun CI | `gh run rerun ID` | `curl POST /repos/o/r/actions/runs/ID/rerun` |
|
||||||
|
| Set secret | `gh secret set KEY` | `curl PUT /repos/o/r/actions/secrets/KEY` (+ encryption) |
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Review Output Template
|
||||||
|
|
||||||
|
Use this as the structure for PR review summary comments. Copy and fill in the sections.
|
||||||
|
|
||||||
|
## For PR Summary Comment
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Code Review Summary
|
||||||
|
|
||||||
|
**Verdict: [Approved ✅ | Changes Requested 🔴 | Reviewed 💬]** ([N] issues, [N] suggestions)
|
||||||
|
|
||||||
|
**PR:** #[number] — [title]
|
||||||
|
**Author:** @[username]
|
||||||
|
**Files changed:** [N] (+[additions] -[deletions])
|
||||||
|
|
||||||
|
### 🔴 Critical
|
||||||
|
<!-- Issues that MUST be fixed before merge -->
|
||||||
|
- **file.py:line** — [description]. Suggestion: [fix].
|
||||||
|
|
||||||
|
### ⚠️ Warnings
|
||||||
|
<!-- Issues that SHOULD be fixed, but not strictly blocking -->
|
||||||
|
- **file.py:line** — [description].
|
||||||
|
|
||||||
|
### 💡 Suggestions
|
||||||
|
<!-- Non-blocking improvements, style preferences, future considerations -->
|
||||||
|
- **file.py:line** — [description].
|
||||||
|
|
||||||
|
### ✅ Looks Good
|
||||||
|
<!-- Call out things done well — positive reinforcement -->
|
||||||
|
- [aspect that was done well]
|
||||||
|
|
||||||
|
---
|
||||||
|
*Reviewed by Hermes Agent*
|
||||||
|
```
|
||||||
|
|
||||||
|
## Severity Guide
|
||||||
|
|
||||||
|
| Level | Icon | When to use | Blocks merge? |
|
||||||
|
|-------|------|-------------|---------------|
|
||||||
|
| Critical | 🔴 | Security vulnerabilities, data loss risk, crashes, broken core functionality | Yes |
|
||||||
|
| Warning | ⚠️ | Bugs in non-critical paths, missing error handling, missing tests for new code | Usually yes |
|
||||||
|
| Suggestion | 💡 | Style improvements, refactoring ideas, performance hints, documentation gaps | No |
|
||||||
|
| Looks Good | ✅ | Clean patterns, good test coverage, clear naming, smart design decisions | N/A |
|
||||||
|
|
||||||
|
## Verdict Decision
|
||||||
|
|
||||||
|
- **Approved ✅** — Zero critical/warning items. Only suggestions or all clear.
|
||||||
|
- **Changes Requested 🔴** — Any critical or warning item exists.
|
||||||
|
- **Reviewed 💬** — Observations only (draft PRs, uncertain findings, informational).
|
||||||
|
|
||||||
|
## For Inline Comments
|
||||||
|
|
||||||
|
Prefix inline comments with the severity icon so they're scannable:
|
||||||
|
|
||||||
|
```
|
||||||
|
🔴 **Critical:** User input passed directly to SQL query — use parameterized queries to prevent injection.
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠️ **Warning:** This error is silently swallowed. At minimum, log it.
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
💡 **Suggestion:** This could be simplified with a dict comprehension:
|
||||||
|
`{k: v for k, v in items if v is not None}`
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ **Nice:** Good use of context manager here — ensures cleanup on exceptions.
|
||||||
|
```
|
||||||
|
|
||||||
|
## For Local (Pre-Push) Review
|
||||||
|
|
||||||
|
When reviewing locally before push, use the same structure but present it as a message to the user instead of a PR comment. Skip the PR metadata header and just start with the severity sections.
|
||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# GitHub environment detection helper for Hermes Agent skills.
|
||||||
|
#
|
||||||
|
# Usage (via terminal tool):
|
||||||
|
# source skills/github/github-auth/scripts/gh-env.sh
|
||||||
|
#
|
||||||
|
# After sourcing, these variables are set:
|
||||||
|
# GH_AUTH_METHOD - "gh", "curl", or "none"
|
||||||
|
# GITHUB_TOKEN - personal access token (set if method is "curl")
|
||||||
|
# GH_USER - GitHub username
|
||||||
|
# GH_OWNER - repo owner (only if inside a git repo with a github remote)
|
||||||
|
# GH_REPO - repo name (only if inside a git repo with a github remote)
|
||||||
|
# GH_OWNER_REPO - owner/repo (only if inside a git repo with a github remote)
|
||||||
|
|
||||||
|
# --- Auth detection ---
|
||||||
|
|
||||||
|
GH_AUTH_METHOD="none"
|
||||||
|
GITHUB_TOKEN="${GITHUB_TOKEN:-}"
|
||||||
|
GH_USER=""
|
||||||
|
|
||||||
|
if command -v gh &>/dev/null && gh auth status &>/dev/null 2>&1; then
|
||||||
|
GH_AUTH_METHOD="gh"
|
||||||
|
GH_USER=$(gh api user --jq '.login' 2>/dev/null)
|
||||||
|
elif [ -n "$GITHUB_TOKEN" ]; then
|
||||||
|
GH_AUTH_METHOD="curl"
|
||||||
|
elif [ -f "$HOME/.hermes/.env" ] && grep -q "^GITHUB_TOKEN=" "$HOME/.hermes/.env" 2>/dev/null; then
|
||||||
|
GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$HOME/.hermes/.env" | head -1 | cut -d= -f2 | tr -d '\n\r')
|
||||||
|
if [ -n "$GITHUB_TOKEN" ]; then
|
||||||
|
GH_AUTH_METHOD="curl"
|
||||||
|
fi
|
||||||
|
elif [ -f "$HOME/.git-credentials" ] && grep -q "github.com" "$HOME/.git-credentials" 2>/dev/null; then
|
||||||
|
GITHUB_TOKEN=$(grep "github.com" "$HOME/.git-credentials" | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|')
|
||||||
|
if [ -n "$GITHUB_TOKEN" ]; then
|
||||||
|
GH_AUTH_METHOD="curl"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve username for curl method
|
||||||
|
if [ "$GH_AUTH_METHOD" = "curl" ] && [ -z "$GH_USER" ]; then
|
||||||
|
GH_USER=$(curl -s -H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
https://api.github.com/user 2>/dev/null \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin).get('login',''))" 2>/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Repo detection (if inside a git repo with a GitHub remote) ---
|
||||||
|
|
||||||
|
GH_OWNER=""
|
||||||
|
GH_REPO=""
|
||||||
|
GH_OWNER_REPO=""
|
||||||
|
|
||||||
|
_remote_url=$(git remote get-url origin 2>/dev/null)
|
||||||
|
if [ -n "$_remote_url" ] && echo "$_remote_url" | grep -q "github.com"; then
|
||||||
|
GH_OWNER_REPO=$(echo "$_remote_url" | sed -E 's|.*github\.com[:/]||; s|\.git$||')
|
||||||
|
GH_OWNER=$(echo "$GH_OWNER_REPO" | cut -d/ -f1)
|
||||||
|
GH_REPO=$(echo "$GH_OWNER_REPO" | cut -d/ -f2)
|
||||||
|
fi
|
||||||
|
unset _remote_url
|
||||||
|
|
||||||
|
# --- Summary ---
|
||||||
|
|
||||||
|
echo "GitHub Auth: $GH_AUTH_METHOD"
|
||||||
|
[ -n "$GH_USER" ] && echo "User: $GH_USER"
|
||||||
|
[ -n "$GH_OWNER_REPO" ] && echo "Repo: $GH_OWNER_REPO"
|
||||||
|
[ "$GH_AUTH_METHOD" = "none" ] && echo "⚠ Not authenticated — see github-auth skill"
|
||||||
|
|
||||||
|
export GH_AUTH_METHOD GITHUB_TOKEN GH_USER GH_OWNER GH_REPO GH_OWNER_REPO
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
## Bug Description
|
||||||
|
|
||||||
|
<!-- Clear, concise description of the bug -->
|
||||||
|
|
||||||
|
## Steps to Reproduce
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
<!-- What should happen -->
|
||||||
|
|
||||||
|
## Actual Behavior
|
||||||
|
|
||||||
|
<!-- What actually happens -->
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- OS:
|
||||||
|
- Version/Commit:
|
||||||
|
- Python version:
|
||||||
|
- Browser (if applicable):
|
||||||
|
|
||||||
|
## Error Output
|
||||||
|
|
||||||
|
<!-- Paste relevant error messages, stack traces, or logs -->
|
||||||
|
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
|
||||||
|
<!-- Screenshots, related issues, workarounds discovered, etc. -->
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## Feature Description
|
||||||
|
|
||||||
|
<!-- What do you want? -->
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
<!-- Why would this be useful? What problem does it solve? -->
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
<!-- How could it work? Include API sketches, CLI examples, or mockups if helpful -->
|
||||||
|
|
||||||
|
```
|
||||||
|
# Example usage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
<!-- Other approaches and why they're less ideal -->
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Scope / Effort Estimate
|
||||||
|
|
||||||
|
<!-- How big is this? What areas of the codebase would it touch? -->
|
||||||
|
|
||||||
|
Small / Medium / Large — <!-- explanation -->
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
|
||||||
|
<!-- Links to similar features in other tools, relevant discussions, etc. -->
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
## Bug Description
|
||||||
|
|
||||||
|
<!-- What was happening? -->
|
||||||
|
|
||||||
|
Fixes #
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
<!-- What was causing the bug? -->
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
<!-- What does this PR change to fix it? -->
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## How to Verify
|
||||||
|
|
||||||
|
<!-- Steps a reviewer can follow to confirm the fix -->
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- [ ] Added regression test for this bug
|
||||||
|
- [ ] Existing tests still pass
|
||||||
|
- [ ] Manual verification of the fix
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
<!-- Could this fix break anything else? What's the blast radius? -->
|
||||||
|
|
||||||
|
Low / Medium / High — <!-- explanation -->
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- 1-3 bullet points describing what this PR does -->
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
<!-- Why is this change needed? Link to issue if applicable -->
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
<!-- Detailed list of changes made -->
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
<!-- How was this tested? Checklist of verification steps -->
|
||||||
|
|
||||||
|
- [ ] Unit tests pass (`pytest`)
|
||||||
|
- [ ] Manual testing of new functionality
|
||||||
|
- [ ] No regressions in existing behavior
|
||||||
|
|
||||||
|
## Screenshots / Examples
|
||||||
|
|
||||||
|
<!-- If UI changes or new output, show before/after -->
|
||||||
|
|
||||||
|
## Notes for Reviewers
|
||||||
|
|
||||||
|
<!-- Anything reviewers should pay special attention to -->
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
---
|
||||||
|
name: case-analysis-nine-steps
|
||||||
|
description: 要件审判九步法——邹碧华法官提出的民事案件分析方法论。以请求权思维为基础,将案件分析分解为九个环环相扣的步骤。适用于案件分析、诉讼策略制定、庭审准备、裁判文书分析。
|
||||||
|
version: 1
|
||||||
|
tags: [legal, litigation, case-analysis, methodology]
|
||||||
|
triggers:
|
||||||
|
- 分析案件
|
||||||
|
- 案件分析
|
||||||
|
- 九步法
|
||||||
|
- 诉讼策略
|
||||||
|
- 请求权分析
|
||||||
|
- 争点整理
|
||||||
|
---
|
||||||
|
|
||||||
|
# 要件审判九步法 — 案件分析方法论
|
||||||
|
|
||||||
|
> 来源:邹碧华法官《要件审判九步法》
|
||||||
|
> 核心思维:先找法(大前提)→ 再认定事实(小前提)→ 归入裁判(结论)
|
||||||
|
|
||||||
|
## 案卷材料分析流程(九步法前置)
|
||||||
|
|
||||||
|
拿到案卷材料后,必须先完成以下步骤,再进入九步法分析:
|
||||||
|
|
||||||
|
### 一、精读全部材料
|
||||||
|
|
||||||
|
- **所有文件逐页阅读**,页眉、页脚、注释、手写内容、印章、批注一律不放过
|
||||||
|
- 标记关键信息:日期、金额、签名、盖章、手写备注
|
||||||
|
- 注意文件之间的交叉引用和矛盾之处
|
||||||
|
|
||||||
|
### 二、梳理相关方与时间线
|
||||||
|
|
||||||
|
- **找出所有相关方**:当事人、关联公司、代理人、第三方等
|
||||||
|
- **按时间顺序梳理全部事件**:合同签订、履行、违约、通知、催告、诉讼等
|
||||||
|
- 制作时间线表格,每个事件标注:时间、相关方、事件内容、对应文件
|
||||||
|
|
||||||
|
### 三、法律行为分析与法律关系提炼
|
||||||
|
|
||||||
|
- 对**每个相关方**逐一进行"法律行为"分析——做了什么、基于什么身份、产生什么法律效果
|
||||||
|
- 提炼**两两相对方之间的法律关系**(合同关系、侵权关系、担保关系、代理关系等)
|
||||||
|
- 总结每组法律关系中各方的**权利义务**
|
||||||
|
|
||||||
|
### 四、推理还原与缺失材料识别
|
||||||
|
|
||||||
|
- 基于已有材料**推理还原事件经过**
|
||||||
|
- **推测可能存在但未提供的文件和事实**(如:有催告函但没有送达凭证、有合同但没有付款凭证)
|
||||||
|
- 标注推测依据
|
||||||
|
|
||||||
|
### 五、制作补充材料清单
|
||||||
|
|
||||||
|
- 列出**需要客户补充的材料和文件**,说明每份材料的用途和重要性
|
||||||
|
- 要求客户提供,必要时说明不提供的风险
|
||||||
|
|
||||||
|
### 六、事实还原定稿
|
||||||
|
|
||||||
|
- 确认收集到所有能够搜集到的资料后,**尽可能还原案件事实全貌**
|
||||||
|
- 形成完整的事实陈述,区分"已证实事实"和"待确认事实"
|
||||||
|
|
||||||
|
完成以上步骤后,进入要件审判九步法进行法律分析。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
|
||||||
|
- 民事案件全面分析
|
||||||
|
- 诉讼策略制定(原告视角/被告视角)
|
||||||
|
- 庭审准备与争点预判
|
||||||
|
- 裁判文书逻辑审查
|
||||||
|
- 合同纠纷、侵权纠纷、物权纠纷等各类民商事案件
|
||||||
|
|
||||||
|
## 九步分析流程
|
||||||
|
|
||||||
|
### 第一步:固定权利请求
|
||||||
|
|
||||||
|
**任务**:明确当事人的诉讼请求是什么。
|
||||||
|
|
||||||
|
- 原告请求什么?(给付金钱、返还财产、确认权利、变更/解除合同等)
|
||||||
|
- 有无反诉?反诉请求是什么?
|
||||||
|
- 诉讼请求是否明确、具体、可执行?
|
||||||
|
- 注意区分:确认之诉、给付之诉、形成之诉
|
||||||
|
|
||||||
|
**输出**:列出全部诉讼请求(含反诉),逐条编号。
|
||||||
|
|
||||||
|
### 第二步:识别权利请求基础规范(法官找法)
|
||||||
|
|
||||||
|
**任务**:为每项诉讼请求找到对应的法律依据(请求权基础)。
|
||||||
|
|
||||||
|
- 该请求权属于什么性质?(合同请求权、侵权请求权、不当得利请求权、物权请求权等)
|
||||||
|
- 对应的具体法律条文是什么?(民法典哪一条、哪部特别法)
|
||||||
|
- 是否存在请求权竞合?如有,分析各请求权基础的利弊
|
||||||
|
- **必须查原文**:引用具体条文,不凭印象
|
||||||
|
|
||||||
|
**输出**:每项请求对应的法律条文及请求权性质。
|
||||||
|
|
||||||
|
### 第三步:识别抗辩权基础规范(对立规范)
|
||||||
|
|
||||||
|
**任务**:预判或识别对方可能/已经提出的抗辩。
|
||||||
|
|
||||||
|
- 权利障碍抗辩(合同无效、未成立等)
|
||||||
|
- 权利消灭抗辩(已清偿、已抵销、已免除等)
|
||||||
|
- 权利阻止抗辩(诉讼时效、同时履行抗辩权、不安抗辩权等)
|
||||||
|
- 每项抗辩对应的法律规范是什么?
|
||||||
|
- 抗辩的举证责任归谁?
|
||||||
|
|
||||||
|
**输出**:抗辩清单及对应法律依据,标注举证责任分配。
|
||||||
|
|
||||||
|
### 第四步:基础规范构成要件分析
|
||||||
|
|
||||||
|
**任务**:将请求权基础规范和抗辩规范的构成要件逐一拆解。
|
||||||
|
|
||||||
|
- 把法律条文分解为若干事实要件(要件事实)
|
||||||
|
- 对不完全法条,通过法律解释、司法解释、指导案例补充隐含要件
|
||||||
|
- 每个要件需要什么事实来满足?
|
||||||
|
|
||||||
|
**输出**:要件分解表——每项请求权/抗辩的构成要件列表。
|
||||||
|
|
||||||
|
**示例格式**:
|
||||||
|
```
|
||||||
|
请求权基础:《民法典》第577条(违约责任)
|
||||||
|
构成要件:
|
||||||
|
1. 合同有效成立
|
||||||
|
2. 被告存在违约行为
|
||||||
|
3. 原告遭受损失
|
||||||
|
4. 违约行为与损失之间有因果关系
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第五步:审查诉讼主张是否完备
|
||||||
|
|
||||||
|
**任务**:检查当事人的主张是否覆盖了全部构成要件。
|
||||||
|
|
||||||
|
- 有无遗漏的主张?(对照第四步的要件清单逐一检查)
|
||||||
|
- 有无矛盾的主张?
|
||||||
|
- 如代表一方,提示需要补充的主张
|
||||||
|
- 如分析裁判,检查法院是否进行了释明
|
||||||
|
|
||||||
|
**输出**:主张完备性检查表,标注缺漏项。
|
||||||
|
|
||||||
|
### 第六步:争点整理
|
||||||
|
|
||||||
|
**任务**:归纳案件的争议焦点。
|
||||||
|
|
||||||
|
- 哪些要件事实双方无争议?(可直接认定)
|
||||||
|
- 哪些要件事实双方有争议?(这就是争点)
|
||||||
|
- 是否存在法律适用争点?(法条理解分歧)
|
||||||
|
- 按重要性和逻辑顺序排列争点
|
||||||
|
|
||||||
|
**输出**:争议焦点清单,按优先级排序。
|
||||||
|
|
||||||
|
### 第七步:要件事实的证明
|
||||||
|
|
||||||
|
**任务**:围绕争点分析证据情况。
|
||||||
|
|
||||||
|
- 每个争点需要什么证据来证明?
|
||||||
|
- 现有证据是否充分?
|
||||||
|
- 举证责任如何分配?(谁主张谁举证,举证责任倒置情形)
|
||||||
|
- 有无证据缺口?如何补强?
|
||||||
|
- 对方证据的薄弱点在哪里?
|
||||||
|
|
||||||
|
**输出**:证据与争点对照表,标注证据充分性和风险点。
|
||||||
|
|
||||||
|
### 第八步:要件事实的认定
|
||||||
|
|
||||||
|
**任务**:基于证据认定事实。
|
||||||
|
|
||||||
|
- 依据构成要件"过滤"证据,排除无关联性证据
|
||||||
|
- 判断证据的真实性、合法性、关联性
|
||||||
|
- 对证据证明力大小作出判断
|
||||||
|
- 确认哪些要件事实可以认定、哪些不能
|
||||||
|
|
||||||
|
**输出**:事实认定结论,逐要件标注"已证明/未证明/证据不足"。
|
||||||
|
|
||||||
|
### 第九步:要件归入并得出结论
|
||||||
|
|
||||||
|
**任务**:将认定的事实归入法律要件,得出最终结论。
|
||||||
|
|
||||||
|
- 逐一比对:每个构成要件是否有事实支撑?
|
||||||
|
- 全部要件满足 → 请求权成立
|
||||||
|
- 任一要件不满足 → 请求权不成立
|
||||||
|
- 抗辩要件是否满足?
|
||||||
|
- 综合得出裁判/分析结论
|
||||||
|
|
||||||
|
**输出**:归入分析表 + 最终结论。
|
||||||
|
|
||||||
|
## 使用原则
|
||||||
|
|
||||||
|
1. **严格按顺序**:九步环环相扣,不跳步
|
||||||
|
2. **法条必须查原文**:涉及具体法律条文必须检索验证,不凭印象
|
||||||
|
3. **区分视角**:明确是站在原告、被告还是中立分析视角
|
||||||
|
4. **要件拆解是核心**:第四步做得越细,后续分析越精准
|
||||||
|
5. **争点是指挥棒**:第六步决定了后续证据分析的方向
|
||||||
|
6. **结论要有证据支撑**:每个判断都要指向具体证据
|
||||||
|
|
||||||
|
## 输出格式建议
|
||||||
|
|
||||||
|
分析报告按九步结构组织,每步包含:
|
||||||
|
- 分析过程
|
||||||
|
- 关键发现
|
||||||
|
- 风险提示(如有)
|
||||||
|
|
||||||
|
最后附总结:案件整体评估、胜诉概率判断(如适用)、策略建议。
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
|||||||
|
# add_clause after_search Fails After tracked_replace — Use Direct lxml Insertion
|
||||||
|
|
||||||
|
## Problem (2026-07-01 凤雅幼儿园劳务派遣协议)
|
||||||
|
|
||||||
|
After calling `ed.tracked_replace(old, new)` on multiple paragraphs, subsequent `ed.add_clause(text, after_search="...")` calls silently fail — the new paragraph doesn't appear in the output. The function returns without error but the clause is not inserted.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
`add_clause`'s `after_search` parameter searches paragraph text by concatenating all `<w:t>` elements. After `tracked_replace`, the paragraph's XML contains interleaved `<w:del>` and `<w:ins>` elements. The `after_search` text-matching logic may:
|
||||||
|
|
||||||
|
1. Include both old (del) and new (ins) text in the concatenation, so neither the old NOR new text matches cleanly
|
||||||
|
2. Match the wrong paragraph if the search string appears in unexpected combinations of del+ins text
|
||||||
|
|
||||||
|
## Solution: Direct lxml `addnext` Insertion
|
||||||
|
|
||||||
|
After all `tracked_replace` calls, insert new clauses directly using lxml:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Find reference paragraph by index or by scanning accepted-view text
|
||||||
|
paras = ed.body.findall(f'{WNS}p')
|
||||||
|
ref_para = paras[target_index] # e.g., P68
|
||||||
|
|
||||||
|
# Build INS paragraph
|
||||||
|
new_p = etree.Element(f'{WNS}p')
|
||||||
|
new_p.append(copy.deepcopy(ref_ppr)) # Clone paragraph formatting
|
||||||
|
|
||||||
|
ins = etree.SubElement(new_p, f'{WNS}ins')
|
||||||
|
ins.set(f'{WNS}id', next_rev_id())
|
||||||
|
ins.set(f'{WNS}author', 'WB')
|
||||||
|
ins.set(f'{WNS}date', rev_date)
|
||||||
|
|
||||||
|
r = etree.SubElement(ins, f'{WNS}r')
|
||||||
|
r.set(f'{WNS}rsidR', rsid)
|
||||||
|
r.insert(0, copy.deepcopy(ref_rpr))
|
||||||
|
|
||||||
|
t = etree.SubElement(r, f'{WNS}t')
|
||||||
|
t.set(XML_SPACE, 'preserve')
|
||||||
|
t.text = clause_text
|
||||||
|
|
||||||
|
# Mark paragraph itself as inserted (pPr/rPr/ins)
|
||||||
|
ppr = new_p.find(f'{WNS}pPr')
|
||||||
|
ppr_rpr = etree.SubElement(ppr, f'{WNS}rPr')
|
||||||
|
ppr_ins = etree.SubElement(ppr_rpr, f'{WNS}ins')
|
||||||
|
ppr_ins.set(f'{WNS}id', next_rev_id())
|
||||||
|
ppr_ins.set(f'{WNS}author', 'WB')
|
||||||
|
ppr_ins.set(f'{WNS}date', rev_date)
|
||||||
|
|
||||||
|
# Insert after reference
|
||||||
|
ref_para.addnext(new_p)
|
||||||
|
ref_para = new_p # Chain subsequent inserts
|
||||||
|
```
|
||||||
|
|
||||||
|
## When This Applies
|
||||||
|
|
||||||
|
- You need to both modify existing clauses (tracked_replace) AND add new clauses in the same editing session
|
||||||
|
- The `after_search` text has been altered by prior tracked_replace calls
|
||||||
|
|
||||||
|
## Correct Operation Order
|
||||||
|
|
||||||
|
1. All `ed.tracked_replace(...)` calls first
|
||||||
|
2. Then find target paragraphs by scanning the body with accepted-view text extraction
|
||||||
|
3. Insert new paragraphs directly via `addnext`
|
||||||
|
4. `ed.validate()` + `ed.save()`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After save, scan paragraphs and confirm new clauses appear in accepted-view text at the expected positions.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# auto_notify_new_file.sh 架构:入队模式 vs 直接执行模式
|
||||||
|
|
||||||
|
## 根因(2026-06-30)
|
||||||
|
|
||||||
|
`auto_notify_new_file.sh` 原本自己启动 `uwf thread exec -c 5`(前台模式)来执行合同审查 workflow。
|
||||||
|
但 hermes ACP 适配器在前台模式下有 asyncio stdin 注册 bug(`KeyError: '0 is not registered'`),
|
||||||
|
导致每次 spawn `hermes acp` 子进程都立即失败,日志中全是 `agent command failed (uwf-hermes)`。
|
||||||
|
|
||||||
|
**对比**:`contract-queue-runner.sh` 使用 `uwf thread exec --background` 模式,正常工作。
|
||||||
|
|
||||||
|
## 当前架构(2026-06-30 修复后)
|
||||||
|
|
||||||
|
```
|
||||||
|
企微收到文件
|
||||||
|
→ auto_notify_new_file.sh (inotifywait 监控)
|
||||||
|
→ 识别 sender (邱律师=QiuTing)
|
||||||
|
→ 上传 Nextcloud 待审查/
|
||||||
|
→ 入队 /tmp/contract-queue/manifest.txt
|
||||||
|
→ 检查 contract-queue-runner.sh 是否在运行,不在则启动
|
||||||
|
→ contract-queue-runner.sh 串行执行(--background 模式)
|
||||||
|
→ uwf thread start + thread exec --background
|
||||||
|
→ uwf-hermes → hermes acp(正常)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键文件
|
||||||
|
|
||||||
|
| 文件 | 路径 | 职责 |
|
||||||
|
|------|------|------|
|
||||||
|
| auto_notify | `~/.hermes/scripts/auto_notify_new_file.sh` | 监控文件到达、上传、入队 |
|
||||||
|
| queue runner | `~/.hermes/skills/devops/uwf/scripts/contract-queue-runner.sh` | 串行执行 workflow |
|
||||||
|
| watchdog | `~/.hermes/scripts/contract-queue-watchdog.sh` | 每20分钟检查卡住的 thread |
|
||||||
|
| auto_notify watchdog | `~/.hermes/scripts/auto_notify_watchdog.sh` | 每5分钟检查 auto_notify 进程 |
|
||||||
|
|
||||||
|
## 禁止回退
|
||||||
|
|
||||||
|
**绝不可把 auto_notify 改回自己启动 `uwf thread exec` 的模式**。前台模式有 ACP stdin bug,
|
||||||
|
只有 `--background` 模式能正常工作。如果未来需要修改 auto_notify 的 workflow 启动逻辑,
|
||||||
|
必须通过 contract-queue-runner 间接执行。
|
||||||
|
|
||||||
|
## 入队逻辑
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 入队到 contract-queue
|
||||||
|
cp "$filepath" "$QUEUE_DIR/${orig_name}"
|
||||||
|
|
||||||
|
# 追加到 manifest(去重)
|
||||||
|
if ! grep -qFx "$orig_name" "$QUEUE_DIR/manifest.txt" 2>/dev/null; then
|
||||||
|
echo "$orig_name" >> "$QUEUE_DIR/manifest.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查 queue runner 是否在运行,不在则启动
|
||||||
|
if ! ps aux | grep -q "[c]ontract-queue-runner"; then
|
||||||
|
nohup bash "$HOME/.hermes/skills/devops/uwf/scripts/contract-queue-runner.sh" >> "$QUEUE_DIR/queue.log" 2>&1 &
|
||||||
|
fi
|
||||||
|
```
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# 自动编号 → 手动固定编号修复(删段重排根治)
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
他人(如屠佳青)用修订模式整段删除了一个**自动编号列表项**(段落 pPr 含 `<w:numPr>`,且段落标记 del=True),导致 OnlyOffice **markup 修订视图**把后续列表项渲染成"旧号新号"双编号:
|
||||||
|
|
||||||
|
```
|
||||||
|
(1)报名服务 ← 正常
|
||||||
|
(2)笔试服务[删除线] ← 被删,仍占编号位
|
||||||
|
(3)(2)面试服务 ← 双号!自动引擎按"接受后会变(2)"提前显示
|
||||||
|
(4)(3)项目管理 ← 双号!
|
||||||
|
```
|
||||||
|
|
||||||
|
Maggie/Doro 平时看 markup 视图,要求"修订视图下编号稳定显示 (1)(2)(3)(4)"。
|
||||||
|
根治办法:把这一组列表项从自动编号转成**手动文本编号**——文本是字面量,渲染器原样输出,不再经过自动编号引擎重排。
|
||||||
|
|
||||||
|
## 关键认知(动手前必须确认)
|
||||||
|
1. **被删项的删除是他人修订 → 绝不动其正文**(只在段首加编号 run,不碰 del 内容)。
|
||||||
|
2. **全文先确认没有对这些子项编号的交叉引用**(如"按上述第3项""见(4)")。本例"具体服务内容详见 附件一:服务报价单"是文字列举,非编号引用 → 安全。若存在引用,转手动编号后引用文字需同步核对。
|
||||||
|
3. 用 `docker exec <nc容器> find ... ` 从 Nextcloud **拉当前交付版**作修复源,`md5sum` 比对确认本地副本没过时。
|
||||||
|
|
||||||
|
## 可复用代码(2026-06-16 验证通过)
|
||||||
|
```python
|
||||||
|
import zipfile, shutil, os
|
||||||
|
from lxml import etree
|
||||||
|
NS='http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||||
|
def q(t): return f'{{{NS}}}{t}'
|
||||||
|
def ln(el): return etree.QName(el).localname
|
||||||
|
|
||||||
|
src='交付版.docx'; out='FIXED.docx'
|
||||||
|
work='work.docx'; shutil.copy(src, work)
|
||||||
|
root=etree.fromstring(zipfile.ZipFile(work).read('word/document.xml'))
|
||||||
|
|
||||||
|
# 1. 按正文开头定位目标段(按你的合同改这些前缀)
|
||||||
|
segs={}
|
||||||
|
for p in root.iter(q('p')):
|
||||||
|
t=''.join((x.text or '') for x in p.iter() if ln(x) in ('t','delText'))
|
||||||
|
if t.startswith('报名服务:'): segs['报名']=p
|
||||||
|
elif t.startswith('笔试服务:提供'): segs['笔试']=p # 被删的那项
|
||||||
|
elif t.startswith('面试服务:'): segs['面试']=p
|
||||||
|
elif t.startswith('项目管理:整个项目'): segs['项目管理']=p
|
||||||
|
|
||||||
|
def make_rpr(): # 字体/字号照搬本段原文(本例宋体sz=24)。务必与目标段一致
|
||||||
|
rpr=etree.SubElement(etree.Element(q('tmp')), q('rPr'))
|
||||||
|
rf=etree.SubElement(rpr, q('rFonts'))
|
||||||
|
for a in ('ascii','hAnsi','cs'): rf.set(q(a),'宋体;SimSun')
|
||||||
|
etree.SubElement(rpr, q('sz')).set(q('val'),'24')
|
||||||
|
etree.SubElement(rpr, q('szCs')).set(q('val'),'24')
|
||||||
|
return rpr
|
||||||
|
|
||||||
|
def make_run(text):
|
||||||
|
r=etree.Element(q('r')); r.append(make_rpr())
|
||||||
|
t=etree.SubElement(r, q('t')); t.text=text
|
||||||
|
t.set('{http://www.w3.org/XML/1998/namespace}space','preserve')
|
||||||
|
return r
|
||||||
|
|
||||||
|
def remove_numpr(p):
|
||||||
|
ppr=p.find(q('pPr'))
|
||||||
|
if ppr is not None:
|
||||||
|
np=ppr.find(q('numPr'))
|
||||||
|
if np is not None: ppr.remove(np)
|
||||||
|
|
||||||
|
def insert_first(p, node): # 插到 pPr 之后、第一个内容元素之前
|
||||||
|
idx=len(p)
|
||||||
|
for i,c in enumerate(p):
|
||||||
|
if ln(c) in ('r','ins','del','hyperlink'): idx=i; break
|
||||||
|
p.insert(idx, node)
|
||||||
|
|
||||||
|
# 2. 普通项:明文编号 run
|
||||||
|
for key,label in [('报名','(1)'),('面试','(3)'),('项目管理','(4)')]:
|
||||||
|
remove_numpr(segs[key]); insert_first(segs[key], make_run(label))
|
||||||
|
|
||||||
|
# 3. 被删项:编号 run 必须包进【他人的】<w:del>(复制其 author/date)
|
||||||
|
p=segs['笔试']; remove_numpr(p)
|
||||||
|
ex=p.find(q('del')) # 已有的他人 del(屠佳青)
|
||||||
|
nd=etree.Element(q('del'))
|
||||||
|
nd.set(q('author'), ex.get(q('author'))) # 照抄他人 author
|
||||||
|
nd.set(q('date'), ex.get(q('date')))
|
||||||
|
nd.set(q('id'),'99001') # 不冲突的大 id
|
||||||
|
r=etree.SubElement(nd, q('r')); r.append(make_rpr())
|
||||||
|
dt=etree.SubElement(r, q('delText')); dt.text='(2)'
|
||||||
|
dt.set('{http://www.w3.org/XML/1998/namespace}space','preserve')
|
||||||
|
insert_first(p, nd)
|
||||||
|
|
||||||
|
# 4. 写回(只换 document.xml,其余条目原样复制)
|
||||||
|
new_doc=etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||||
|
with zipfile.ZipFile(work) as zin, zipfile.ZipFile(out,'w',zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for it in zin.namelist():
|
||||||
|
zout.writestr(it, new_doc if it=='word/document.xml' else zin.read(it))
|
||||||
|
```
|
||||||
|
|
||||||
|
## 交付前必验(vision 不可用时降级四查,缺一不可)
|
||||||
|
1. **逐段 markup diff vs 交付源** → 只有目标 N 段不同,其余零改动(本例 346 段只动 4 段)。
|
||||||
|
2. **pdftotext 渲染层数编号链** → `onlyoffice-render.sh out.docx && pdftotext -f1 -l1 out.pdf -` 确认 1.2 下严格 (1)(2)(3)(4) 无双号。
|
||||||
|
3. **编号 run rPr == 本段正文 run rPr**(字体宋体、sz=24 一致)。
|
||||||
|
4. **接受修订后视图编号链连续** + `python-docx Document(out)` 可打开(XML 合法)。验证被删项的编号确实在 `<del author=他人>` 里、他人原 del 内容(id 不变)一字未动。
|
||||||
|
|
||||||
|
## 上传
|
||||||
|
`docker cp` 覆盖 `任务交付/` 同名文件 → `chown www-data` → `occ files:scan --path` → 清 OnlyOffice 缓存(`rm -rf .../App_Data/cache/files/*`)让 Maggie 打开看到新版。`md5sum` 比对容器内==本地确认上传成功。
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# 在「自动编号的顶层列表」中新增条款(保留numPr)
|
||||||
|
|
||||||
|
2026-06-18 华新慢病运维合同实战。一次返工换来的教训。
|
||||||
|
|
||||||
|
## 适用判定(动手前先分清两类合同)
|
||||||
|
|
||||||
|
合同的「条款」分两种承载方式,新增条款的手法完全不同:
|
||||||
|
|
||||||
|
| 类型 | 特征 | 新增条款手法 |
|
||||||
|
|------|------|-------------|
|
||||||
|
| **A. 第X条 文本标题** | 条款标题是 run 里的文字「第七条 …」/「7. …」,段落**无** numPr | `add_clause()`(库会剥 numPr,正确)|
|
||||||
|
| **B. 自动编号列表项** | 条款本身是自动编号列表项:段落 pPr 带 `<w:numPr>`,编号由 numbering.xml 的 `start`+`lvlText`(如 `一、`/`1.`/`(1)`)自动渲染,run 里**没有**编号文字 | ❌ 不能用 `add_clause()`;按下方「numbered-insert」手法 |
|
||||||
|
|
||||||
|
**判定脚本**:对要插入位置附近的条款段落跑 `numbering-diagnose.py`,或直接看锚点段 `pPr/numPr` 是否存在且其 numId 的 lvlText 是序号格式。本案锚点「五、违约责任」段 `pPr` = `pStyle=12 + numPr(numId=1,ilvl=0) + ind`,numId=1→abstractNum start=1 lvlText=`%1、`(japaneseCounting 一、二、三)。
|
||||||
|
|
||||||
|
## 为什么 add_clause 在 B 类会坏
|
||||||
|
|
||||||
|
`add_clause()`(contract_docx_lib.py 第408-411行)**无条件**剥离新段的 numPr:
|
||||||
|
```python
|
||||||
|
numpr = new_ppr.find(qn('numPr'))
|
||||||
|
if numpr is not None:
|
||||||
|
new_ppr.remove(numpr) # ← B类灾难
|
||||||
|
```
|
||||||
|
后果(本案实测):5 个新增条款全部**丢失自动编号**,且因 pPr 缺 numPr/缩进与列表项不一致,渲染时**堆到了文档最末尾**(签署页之前),既无编号又错位——违反「新增条款插在逻辑对应位置、不堆到最后」+「自动编号保留numPr」两条规则。
|
||||||
|
|
||||||
|
`add_clause` 第二个缺陷:它只把**文本** run 包进 w:ins,**没有把段落标记(¶)标记为插入**。B 类里 ¶ 承载着自动编号,¶ 不是 tracked-insert,则接受/拒绝修订时这一项的编号增减不随修订走。
|
||||||
|
|
||||||
|
## 正确手法:numbered tracked-insert 段落
|
||||||
|
|
||||||
|
克隆锚点段的 pPr(**保留** numPr,让新段成为同一自动编号序列的一员),并把**段落标记本身**也标成 w:ins:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, copy
|
||||||
|
sys.path.insert(0, '/home/maggie/contract-work')
|
||||||
|
from contract_docx_lib import ContractEditor, qn
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
ed = ContractEditor(src)
|
||||||
|
|
||||||
|
# 1) 定位锚点段(要插在它之后的那条原文条款)
|
||||||
|
anchor = None
|
||||||
|
for p in ed.body.findall(qn('p')):
|
||||||
|
if '违约责任:按照中华人民共和国民法典' in ed.get_para_text(p):
|
||||||
|
anchor = p; break
|
||||||
|
anchor_ppr = anchor.find(qn('pPr'))
|
||||||
|
assert anchor_ppr.find(qn('numPr')) is not None, "锚点不是自动编号项,确认是否B类"
|
||||||
|
|
||||||
|
def make_numbered_ins_para(text):
|
||||||
|
new_p = etree.Element(qn('p'))
|
||||||
|
new_ppr = copy.deepcopy(anchor_ppr) # 含 pStyle + numPr(同numId/ilvl) + ind → 入同一自动编号序列
|
||||||
|
# 关键:把段落标记(¶)标成插入,整段(含自动编号)作为 tracked insertion
|
||||||
|
rpr_mark = new_ppr.find(qn('rPr'))
|
||||||
|
if rpr_mark is None:
|
||||||
|
rpr_mark = etree.SubElement(new_ppr, qn('rPr'))
|
||||||
|
ins_mark = etree.SubElement(rpr_mark, qn('ins'))
|
||||||
|
ins_mark.set(qn('id'), ed._next_id())
|
||||||
|
ins_mark.set(qn('author'), 'WB')
|
||||||
|
ins_mark.set(qn('date'), ed._revision_date)
|
||||||
|
new_p.append(new_ppr)
|
||||||
|
# 文本作为 tracked-ins run,用规范化 _body_rpr(完整rFonts四属性+hint=eastAsia+显式sz)
|
||||||
|
new_p.append(ed._mk_ins(text, ed._body_rpr))
|
||||||
|
return new_p
|
||||||
|
|
||||||
|
clauses = [ # 期望的最终正序 六~十
|
||||||
|
"保密与数据:……",
|
||||||
|
"知识产权与系统交接:……",
|
||||||
|
"转包与分包:……",
|
||||||
|
"第三方侵权:……",
|
||||||
|
"违约赔偿:……",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 2) 全部插在 anchor 之后;倒序 insert 使最终正序
|
||||||
|
parent = ed.body
|
||||||
|
anchor_idx = list(parent).index(anchor)
|
||||||
|
for txt in reversed(clauses):
|
||||||
|
parent.insert(anchor_idx + 1, make_numbered_ins_para(txt))
|
||||||
|
|
||||||
|
assert ed.validate() == []
|
||||||
|
ed.save(out)
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
- **倒序插入**:每条都插在 `anchor_idx+1`,倒序遍历 → 最终正序。
|
||||||
|
- **同一 numId/ilvl**:克隆锚点 pPr 即自动继承,新条款自动续编(本案锚点是五 → 新条款渲染为六、七、八、九、十,后续原文自动顺延为十一、十二…,**无需手动改任何原文编号**)。
|
||||||
|
- **¶ 标插入** + **文本 run 标插入**,两者都要,缺一不可。
|
||||||
|
- 文本 run 用 `ed._body_rpr`(库已规整:四属性 rFonts + hint=eastAsia + 显式 sz),不要手搓 rPr。
|
||||||
|
|
||||||
|
## 交付前验证(B 类专项)
|
||||||
|
|
||||||
|
1. **接受所有修订后**渲染(删 w:del + 删带 `pPr/rPr/del` 的整段 + 解包 w:ins)→ 确认新条款编号与锚点连续、原文顺延正确、无错位到末尾。
|
||||||
|
2. **字体核对走「同段原文」标准**:本案原文正文 run = `<w:rFonts hint="eastAsia"/><w:szCs val="21"/>`(**无**显式 eastAsia 名,继承 docDefaults 宋体)。新 INS run 与之等效即合格——`ea=None hint=eastAsia` 是**正确**的,`wb-ins-font-verify.py` 若按绝对属性报 `ea=None` 是假阳性(见 contract-reviewer 的 2026-06-17 培训合同条)。唯一差异是 INS 多了显式 `<w:sz val="21">`(w:ins 必需),渲染一致。
|
||||||
|
3. **LibreOffice 渲染假象**:用 `libreoffice→pdftotext` 自查时,被顺延的自动编号项会显示 `十二、[七、]` 这种**方括号叠加**(recomputed 新号 + cached 旧号),这是 LibreOffice markup 渲染产物,**不是错误**,XML 里没有字面方括号。判真实编号一律以「接受所有修订后」或 OnlyOffice 渲染为准(OnlyOffice 是 Maggie/Doro 实际所用引擎)。
|
||||||
|
|
||||||
|
## 锚点选择铁律:插在 body text 之后,不是 heading 之后
|
||||||
|
|
||||||
|
**这是一个极易犯的错误**(2026-06-26 朱家角环保袋合同实证):
|
||||||
|
|
||||||
|
当Reviewer要求"在违约责任条款之后、争议解决条款之前新增XX条款"时,合同结构通常是:
|
||||||
|
```
|
||||||
|
P74: 八.违约责任 ← heading(numId=1)
|
||||||
|
P75: 若乙方未按本合同... ← body text(无 numPr)
|
||||||
|
P76: 九.合同金额 ← 下一个 heading(numId=1)
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误做法**:锚点 = P74(heading),插入后 → 新条款夹在 heading 和它的 body text 之间,结构错乱。
|
||||||
|
|
||||||
|
**正确做法**:锚点 = P75(body text),插入后 → 新条款在 body text 之后、下一个 heading 之前,结构正确。
|
||||||
|
|
||||||
|
**判据**:`numbering-diagnose.py` 确认锚点段的 `numPr` 状态——heading 有 numPr,body text 无 numPr。新条款应克隆**下一个 heading**(如 P76 合同金额)的 pPr(含 numPr),插入在**前一个 body text**(如 P75)之后。
|
||||||
|
|
||||||
|
## 一句话
|
||||||
|
|
||||||
|
锚点是自动编号列表项(pPr 有 numPr)→ 别用 add_clause,克隆锚点 pPr(留 numPr)+ ¶ 标 w:ins + 文本标 w:ins,倒序插入,新条款自动续编、原文自动顺延。**插在 body text 之后,不是 heading 之后。**
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# A类(手动文本编号)新增条款:克隆"真实邻居段落"而非信任库的 _title_rpr / _body_rpr
|
||||||
|
|
||||||
|
2026-06-18 赵巷镇 X线设备采购合同实战。终审字体核验抓出"新标题不加粗",根因是库提取的标题格式丢了 bold。
|
||||||
|
|
||||||
|
## 何时用这套手法
|
||||||
|
|
||||||
|
- 合同是 **A 类**:条款标题是 run 里的**手动文本编号**(如 `8.争端的解决`、`第七条 索赔`),段落**无** numPr。
|
||||||
|
- 要新增一个带标题的条款(标题段 + 正文段),并希望格式与兄弟条款 100% 一致。
|
||||||
|
- (B 类自动编号列表项见 `auto-numbered-list-clause-insert.md`,手法不同。)
|
||||||
|
|
||||||
|
## 为什么不直接用库的 `_title_rpr` / `_body_rpr`
|
||||||
|
|
||||||
|
`ContractEditor._extract_formats()`(contract_docx_lib.py ~第86-175行)用启发式认"条款标题":
|
||||||
|
```python
|
||||||
|
is_clause_title = (re.match(r'^\d+[..、]\s*\S', p_text) or
|
||||||
|
re.match(r'^第[一二三四五六七八九十百千\d]+条\s*\S', p_text)) and len(p_text) < 30
|
||||||
|
# 且 _is_title_style() 要 <w:b/> 或标题字体(黑体/SimHei…) 才算 title
|
||||||
|
```
|
||||||
|
**坑**:当标题就是"8.争端的解决"(宋体 + `<w:b/>`,无特殊标题字体),若该段在扫描中**没被 `_is_title_style` 命中**(例如 b 标记在 bCs 旁、或正则边界),`clause_title_rpr` / `first_bold_rpr` 取空 → `_title_rpr` **回退到 `_body_rpr`(不含 bold)**。
|
||||||
|
|
||||||
|
实测后果:新标题 `8.转包与分包` 的 INS run `bold=False`,而原文兄弟标题 `9.争端的解决` `bold=True`。`validate()` 查不出(它不比 bold),只有逐段 WB INS 与"同段/同级原文"对比才抓得到。
|
||||||
|
|
||||||
|
## 稳健手法:克隆紧邻同级原文段落
|
||||||
|
|
||||||
|
不取库的 `_title_rpr`/`_body_rpr`,改为**直接深拷贝隔壁真实条款段**的 pPr 和首个 run 的 rPr:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, copy
|
||||||
|
sys.path.insert(0, '/home/maggie/contract-work')
|
||||||
|
from contract_docx_lib import ContractEditor, qn
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
ed = ContractEditor(src) # 已先做完所有 tracked_replace
|
||||||
|
|
||||||
|
def find(kw):
|
||||||
|
for p in ed.body.findall(qn('p')):
|
||||||
|
if kw in ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}')):
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 克隆来源:插入点后面那条原文条款的【标题段】和它的【正文段】
|
||||||
|
title_src = find('8.争端的解决') # 兄弟条款标题(自带 <w:b/> + 宋体四属性 + 标题pPr缩进)
|
||||||
|
body_src = find('双方如在履行合同中发生纠纷') # 兄弟条款正文(无bold + firstLine=420 缩进)
|
||||||
|
|
||||||
|
def clone_as_ins(src_para, new_text):
|
||||||
|
"""深拷贝 src_para 的 pPr + 首run rPr,替换文本,整段(含¶)标 w:ins(author=WB)"""
|
||||||
|
np = etree.Element(qn('p'))
|
||||||
|
ppr = copy.deepcopy(src_para.find(qn('pPr')))
|
||||||
|
# ¶ 段落标记标插入
|
||||||
|
rprm = ppr.find(qn('rPr'))
|
||||||
|
if rprm is None:
|
||||||
|
rprm = etree.SubElement(ppr, qn('rPr'))
|
||||||
|
insm = etree.SubElement(rprm, qn('ins'))
|
||||||
|
insm.set(qn('id'), ed._next_id()); insm.set(qn('author'), 'WB'); insm.set(qn('date'), ed._revision_date)
|
||||||
|
np.append(ppr)
|
||||||
|
# run rPr 直接克隆兄弟段首 run(bold/字体/字号全继承,不碰库的默认值)
|
||||||
|
src_r = src_para.find(qn('r'))
|
||||||
|
src_rpr = copy.deepcopy(src_r.find(qn('rPr'))) if (src_r is not None and src_r.find(qn('rPr')) is not None) else None
|
||||||
|
np.append(ed._mk_ins(new_text, src_rpr))
|
||||||
|
return np
|
||||||
|
|
||||||
|
title_p = clone_as_ins(title_src, "8.转包与分包")
|
||||||
|
body_p = clone_as_ins(body_src, "未经甲方书面同意,乙方不得将本合同项下的…连带责任。")
|
||||||
|
|
||||||
|
idx = list(ed.body).index(title_src)
|
||||||
|
ed.body.insert(idx, title_p) # 标题插在兄弟条款标题之前 → 成为新的"8.",兄弟顺延为"9."
|
||||||
|
ed.body.insert(idx + 1, body_p)
|
||||||
|
```
|
||||||
|
|
||||||
|
## A类手动编号的顺延(与 B 类自动顺延不同!)
|
||||||
|
|
||||||
|
A 类编号是 run 里的字面文字,**不会自动顺延**。插入新"8."后,必须手动把后续所有手动编号 DEL 旧号+INS 新号(用 tracked_replace):
|
||||||
|
```python
|
||||||
|
for old, new in [("8.争端的解决","9.争端的解决"), ("9.合同生效","10.合同生效"),
|
||||||
|
("9.1 本合同在…","10.1 本合同在…"), ("10.合同附件","11.合同附件"),
|
||||||
|
("10.1 配置清单","11.1 配置清单"), ..., ("12.特别约定","13.特别约定")]:
|
||||||
|
ed.tracked_replace(old, new)
|
||||||
|
```
|
||||||
|
- **子编号一并顺延**(9.1/9.2→10.1/10.2,11.1-11.7→12.1-12.7)。
|
||||||
|
- 匹配串要够长以避免短串误命中(见 SKILL.md「tracked_replace 短字符串误命中」)。
|
||||||
|
|
||||||
|
## 交付前验证(必做)
|
||||||
|
|
||||||
|
1. **bold 对照**:新标题 INS run `bold==True` 且 ==兄弟标题;新正文 INS run `bold==False` 且有正确 `firstLine` 缩进。
|
||||||
|
```python
|
||||||
|
r = p.find('.//w:ins/w:r', ns); b = r.find('w:rPr/w:b', ns)
|
||||||
|
# 标题段 b is not None == 兄弟标题段 b is not None
|
||||||
|
```
|
||||||
|
2. **WB INS 字体逐段核验(相对同段/同级原文)**:异常应为 0。原文 run 有显式宋体四属性时,克隆来的 INS 也带四属性——与原文一致即合格。
|
||||||
|
3. **接受所有修订后渲染**,确认手动编号链连续(…7、**8.转包**、9、10、10.1、10.2、11…13),无重号/跳号。
|
||||||
|
4. python-docx 能打开(XML 合法)。
|
||||||
|
|
||||||
|
## 一句话
|
||||||
|
|
||||||
|
A 类手动编号合同新增带标题条款:**别用库的 `_title_rpr`/`_body_rpr`(启发式可能丢 bold)**,直接 `copy.deepcopy` 紧邻兄弟条款的【标题段】和【正文段】的 pPr+首run rPr,文本替换+整段标 w:ins;编号不会自动顺延,手动 tracked_replace 把后续主/子编号全部 +1。
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Comment Restoration from Original File
|
||||||
|
|
||||||
|
When comments are lost during docx editing (e.g., paragraph clear operations that remove commentRangeStart/End/Reference elements), restore them from the original file.
|
||||||
|
|
||||||
|
## Scenario
|
||||||
|
- Original file has N comments (e.g., Alice×2, 法务, 杜律 = 4 comments, ids 0-3)
|
||||||
|
- Edited file lost some/all original comments and may have added new ones (e.g., 华诚-Z comment id=0, Alice id=2)
|
||||||
|
- Goal: merge all comments — original ones preserved + new ones added, with non-conflicting IDs
|
||||||
|
|
||||||
|
## Recovery Technique
|
||||||
|
|
||||||
|
### Step 1: Extract original comments
|
||||||
|
```python
|
||||||
|
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||||
|
|
||||||
|
z_orig = zipfile.ZipFile('original.docx')
|
||||||
|
with z_orig.open('word/comments.xml') as f:
|
||||||
|
ctree_orig = etree.parse(f)
|
||||||
|
orig_comments = []
|
||||||
|
for c in ctree_orig.getroot().findall(f'{WNS}comment'):
|
||||||
|
orig_comments.append({
|
||||||
|
'id': c.get(f'{WNS}id'),
|
||||||
|
'author': c.get(f'{WNS}author'),
|
||||||
|
'date': c.get(f'{WNS}date'),
|
||||||
|
'text': ''.join(t.text for t in c.iter(f'{WNS}t') if t.text),
|
||||||
|
'element': copy.deepcopy(c)
|
||||||
|
})
|
||||||
|
z_orig.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Identify which comments survived in the edited file
|
||||||
|
```python
|
||||||
|
z_edit = zipfile.ZipFile('edited.docx')
|
||||||
|
with z_edit.open('word/comments.xml') as f:
|
||||||
|
ctree_edit = etree.parse(f)
|
||||||
|
edit_comment_ids = set()
|
||||||
|
for c in ctree_edit.getroot().findall(f'{WNS}comment'):
|
||||||
|
edit_comment_ids.add(c.get(f'{WNS}id'))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Find new comments (non-original authors)
|
||||||
|
```python
|
||||||
|
new_comments = []
|
||||||
|
for c in ctree_edit.getroot().findall(f'{WNS}comment'):
|
||||||
|
if c.get(f'{WNS}author') not in [oc['author'] for oc in orig_comments]:
|
||||||
|
new_comments.append({
|
||||||
|
'old_id': c.get(f'{WNS}id'),
|
||||||
|
'author': c.get(f'{WNS}author'),
|
||||||
|
'element': copy.deepcopy(c)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Rebuild comments.xml with all comments
|
||||||
|
Assign non-conflicting IDs:
|
||||||
|
- Original comments keep their original IDs (0, 1, 2, 3)
|
||||||
|
- New comments get IDs starting from max(original_ids) + 1
|
||||||
|
|
||||||
|
```python
|
||||||
|
new_comments_xml = etree.Element(f'{WNS}comments')
|
||||||
|
new_comments_xml.set('xmlns:w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main')
|
||||||
|
# ... add other namespaces as needed
|
||||||
|
|
||||||
|
max_id = max(int(oc['id']) for oc in orig_comments)
|
||||||
|
|
||||||
|
# Add original comments
|
||||||
|
for oc in orig_comments:
|
||||||
|
new_comments_xml.append(oc['element'])
|
||||||
|
|
||||||
|
# Add new comments with renumbered IDs
|
||||||
|
for nc in new_comments:
|
||||||
|
max_id += 1
|
||||||
|
nc['new_id'] = str(max_id)
|
||||||
|
nc['element'].set(f'{WNS}id', nc['new_id'])
|
||||||
|
new_comments_xml.append(nc['element'])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Update document.xml comment references
|
||||||
|
For each new comment, find its commentRangeStart, commentRangeEnd, and commentReference in document.xml and update the ID from old to new:
|
||||||
|
|
||||||
|
```python
|
||||||
|
for nc in new_comments:
|
||||||
|
old_id = nc['old_id']
|
||||||
|
new_id = nc['new_id']
|
||||||
|
|
||||||
|
# Update commentRangeStart
|
||||||
|
for elem in root.iter(f'{WNS}commentRangeStart'):
|
||||||
|
if elem.get(f'{WNS}id') == old_id:
|
||||||
|
elem.set(f'{WNS}id', new_id)
|
||||||
|
|
||||||
|
# Update commentRangeEnd
|
||||||
|
for elem in root.iter(f'{WNS}commentRangeEnd'):
|
||||||
|
if elem.get(f'{WNS}id') == old_id:
|
||||||
|
elem.set(f'{WNS}id', new_id)
|
||||||
|
|
||||||
|
# Update commentReference (inside w:r)
|
||||||
|
for elem in root.iter(f'{WNS}commentReference'):
|
||||||
|
if elem.get(f'{WNS}id') == old_id:
|
||||||
|
elem.set(f'{WNS}id', new_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Write back to docx
|
||||||
|
```python
|
||||||
|
z_out = zipfile.ZipFile('output.docx', 'w')
|
||||||
|
# Copy all files from edited.docx except comments.xml and document.xml
|
||||||
|
for item in z_edit.namelist():
|
||||||
|
if item not in ('word/comments.xml', 'word/document.xml'):
|
||||||
|
z_out.writestr(item, z_edit.read(item))
|
||||||
|
|
||||||
|
# Write updated comments.xml
|
||||||
|
z_out.writestr('word/comments.xml',
|
||||||
|
etree.tostring(new_comments_xml, encoding='UTF-8', xml_declaration=True, standalone=True))
|
||||||
|
|
||||||
|
# Write updated document.xml
|
||||||
|
z_out.writestr('word/document.xml',
|
||||||
|
etree.tostring(tree, encoding='UTF-8', xml_declaration=True, standalone=True))
|
||||||
|
|
||||||
|
z_edit.close()
|
||||||
|
z_out.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
```python
|
||||||
|
z = zipfile.ZipFile('output.docx')
|
||||||
|
with z.open('word/comments.xml') as f:
|
||||||
|
ctree = etree.parse(f)
|
||||||
|
for c in ctree.getroot().findall(f'{WNS}comment'):
|
||||||
|
print(f" id={c.get(f'{WNS}id')} author={c.get(f'{WNS}author')}: {text[:80]}")
|
||||||
|
|
||||||
|
# Check all IDs referenced in document.xml exist in comments.xml
|
||||||
|
content = z.read('word/document.xml').decode('utf-8')
|
||||||
|
doc_ids = set(re.findall(r'commentRangeStart[^>]*w:id="(\d+)"', content))
|
||||||
|
doc_ids |= set(re.findall(r'commentRangeEnd[^>]*w:id="(\d+)"', content))
|
||||||
|
doc_ids |= set(re.findall(r'commentReference[^>]*w:id="(\d+)"', content))
|
||||||
|
comment_ids = set(c.get(f'{WNS}id') for c in ctree.getroot().findall(f'{WNS}comment'))
|
||||||
|
assert doc_ids == comment_ids, f"ID mismatch: doc={doc_ids} comments={comment_ids}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Pitfall: Comment Text Extraction
|
||||||
|
When extracting comment text for comparison, comments may have nested `<w:p>` elements (multi-paragraph comments). Use `.iter()` not `.findall()` to get all text nodes.
|
||||||
|
|
||||||
|
## Empirical Case (2026-07-01 反委托代发工资协议)
|
||||||
|
- Original: 4 comments (Alice id=0, Alice id=1, 法务 id=2, 杜律 id=3)
|
||||||
|
- v1_doro_updated: 2 comments (华诚-Z id=0, Alice id=2) — lost Alice id=0/1, 法务, 杜律
|
||||||
|
- Final: 5 comments (Alice id=0, Alice id=1, 法务 id=2, 杜律 id=3, 华诚-Z id=4)
|
||||||
|
- 华诚-Z's comment was id=0 in v1_doro_updated, renumbered to id=4 in final
|
||||||
|
- All commentRangeStart/End/Reference IDs updated in document.xml accordingly
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# 合同模板修订工作流(非workflow场景)
|
||||||
|
|
||||||
|
## 触发条件
|
||||||
|
用户要求参考一份新合同模板(保护甲方),将有利内容用修订模式改进原合同(乙方模板)。
|
||||||
|
|
||||||
|
## 与标准 review-contract workflow 的区别
|
||||||
|
- 不涉及 classifier/reviewer/editor/deliverer 角色链
|
||||||
|
- 不使用 review-rules.md
|
||||||
|
- 不需要 pass 流程(不写 tracker/xlsx)
|
||||||
|
- 直接用 ContractEditor 库手动修订
|
||||||
|
|
||||||
|
## 操作步骤
|
||||||
|
|
||||||
|
### 1. 读取两份合同
|
||||||
|
```python
|
||||||
|
from contract_docx_lib import ContractEditor
|
||||||
|
editor = ContractEditor('原合同.docx') # 乙方模板,作为修订基底
|
||||||
|
```
|
||||||
|
同时用 python-docx 或 zipfile+lxml 读取新合同全文,逐条对比差异。
|
||||||
|
|
||||||
|
### 2. 识别差异并分类
|
||||||
|
- **可直接移植**:新合同中明确有利于甲方的条款(如违约金降低、管辖权、解除权限制)
|
||||||
|
- **需要调整**:新合同有利但需适配原合同结构/编号的条款
|
||||||
|
- **需要补充**:新合同仍未覆盖的保护甲方的内容(根据法律法规判断)
|
||||||
|
|
||||||
|
### 3. 执行修订(最小化修改原则)
|
||||||
|
- 整体格式、编号逻辑按**原合同**来
|
||||||
|
- 用 `tracked_replace` 修改既有条款
|
||||||
|
- 用 `add_clause` 新增条款(插在合同逻辑对应位置)
|
||||||
|
- author=WB
|
||||||
|
|
||||||
|
### 4. 法律研究(严禁凭记忆)
|
||||||
|
每次修订前必须查证:
|
||||||
|
- 最新法律法规(民法典、劳动合同法、劳务派遣暂行规定等)
|
||||||
|
- 上海地区地方规定和司法实践
|
||||||
|
- 行业惯例
|
||||||
|
|
||||||
|
常见需要查证的点:
|
||||||
|
- 违约金比例上限(司法实践中过高会被调整)
|
||||||
|
- 管辖权约定(甲方所在地法院 vs 仲裁)
|
||||||
|
- 劳务派遣的法定退回情形(劳动合同法第65条)
|
||||||
|
- 雇主责任险要求(上海地区实务惯例)
|
||||||
|
- 经济补偿金的法定标准
|
||||||
|
|
||||||
|
### 5. 修订说明
|
||||||
|
完成后向用户汇报:
|
||||||
|
- 修订数量(insertions/deletions)
|
||||||
|
- 每项修订的法律依据
|
||||||
|
- 标注哪些是根据新合同移植、哪些是独立判断补充
|
||||||
|
|
||||||
|
## 违约后果公式(核心原则,2026-06-29 Doro纠正)
|
||||||
|
|
||||||
|
**"权利是法律给的,关键在违约后果"**——当法律已赋予甲方某项权利时,合同中简单写入"甲方有权XX"只是重复法律,没有实质保护价值。审查/修订的重点是**违约后果条款**:
|
||||||
|
|
||||||
|
### 标准违约后果公式
|
||||||
|
```
|
||||||
|
甲方因此支付的一切费用、承担的赔偿或补偿金、损失等由乙方全额赔偿,
|
||||||
|
乙方另向甲方支付违约金人民币 元。
|
||||||
|
如对甲方造成其他不良影响的,乙方还应当消除一切影响。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 三要素
|
||||||
|
1. **赔偿范围**:一切费用、承担的赔偿或补偿金、损失等(括注具体类型如重新招聘费用、行政罚款、律师费、诉讼费等)
|
||||||
|
2. **违约金**:金额留空(6个空格),由甲方根据实际用工规模和风险自行填写
|
||||||
|
3. **消除影响**:兜底,覆盖名誉损害、商誉损失等非经济损失
|
||||||
|
|
||||||
|
### 适用场景
|
||||||
|
所有"乙方违反法定义务→甲方有权XX"类条款:
|
||||||
|
- 资质丧失 → 不止"甲方有权解除",要追加完整后果公式
|
||||||
|
- 克扣工资/欠缴社保 → 不止"暂停付款",要追加连带后果公式
|
||||||
|
- 一般违约追偿 → 不止"有权追偿",要写清赔偿范围+违约金+消除影响
|
||||||
|
|
||||||
|
### 劳务派遣协议实证(2026-06-29)
|
||||||
|
| 条款 | 原写法(弱) | 改后(含后果公式) |
|
||||||
|
|------|------------|-------------------|
|
||||||
|
| 资质丧失 | "甲方有权解除,乙方赔偿全部损失" | "乙方赔偿一切费用/赔偿或补偿金/损失(含重新招聘费、劳动者赔偿金、行政罚款、律师费等)+违约金___元+消除一切影响" |
|
||||||
|
| 审核权 | "暂停支付相关费用直至整改完成" | 追加:因乙方违法行为导致甲方承担连带责任的,一切费用由乙方赔偿+违约金+消除影响 |
|
||||||
|
| 一般追偿 | "甲方有权依法向乙方追偿" | "一切费用由乙方赔偿+违约金+消除一切影响" |
|
||||||
|
|
||||||
|
## 2026-06-29 劳务派遣协议案修订清单
|
||||||
|
|
||||||
|
| 修订 | 类型 | 法律依据 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 乙方资质持续保证 | 新增 | 《劳务派遣暂行规定》第17条 |
|
||||||
|
| 甲方监督检查权扩展 | 修改 | 《劳动合同法》第62条 |
|
||||||
|
| 甲方调整岗位权 | 新增 | 《劳动合同法》第62条 |
|
||||||
|
| 甲方随时退回权 | 新增 | 《劳动合同法》第65条、《劳务派遣暂行规定》第12条 |
|
||||||
|
| 雇主责任险要求 | 新增 | 上海司法实践惯例 |
|
||||||
|
| 乙方解除权限制 | 修改 | 《民法典》第563条(催告程序) |
|
||||||
|
| 付款期限延长 | 修改 | 商业条款(甲方资金调度) |
|
||||||
|
| 甲方违约金降低 | 修改 | 上海法院对过高违约金的司法调整 |
|
||||||
|
| 乙方根本违约情形 | 新增 | 《民法典》第563条 |
|
||||||
|
| 争议解决管辖 | 新增 | 《民事诉讼法》第35条(协议管辖) |
|
||||||
|
| 附件和补充协议 | 新增 | 标准合同条款 |
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 跨境并购费用参考(5000万人民币交易规模)
|
||||||
|
|
||||||
|
> 来源:行业公开数据与市场实践,2026年6月。具体费用因交易复杂度、目标法域、各方谈判能力而异。
|
||||||
|
|
||||||
|
## 各角色费用区间
|
||||||
|
|
||||||
|
| 角色 | 费用(人民币) | 收费模式 |
|
||||||
|
|---|---|---|
|
||||||
|
| 财务顾问(FA) | 150万–250万 | 成功费,交易对价3%–5%;分期收取(签约10–20%,签约后40%,交割后40–50%) |
|
||||||
|
| 法律顾问(中国律所) | 50万–100万 | 固定费,含法律尽调15–30万、交易文件20–40万、监管审批10–20万、境外律师协调5–10万 |
|
||||||
|
| 境外律师 | 30万–80万 | 按小时(300–800美元/小时),目标法域决定 |
|
||||||
|
| 会计师(财务尽调) | 20万–40万 | 固定费 |
|
||||||
|
| 税务师 | 15万–35万 | 固定费,含税务尽调10–20万、结构优化5–15万 |
|
||||||
|
| **合计** | **265万–505万** | 占交易额约5%–10% |
|
||||||
|
|
||||||
|
## FA 费率惯例
|
||||||
|
|
||||||
|
- 中国市场:中端交易3%–5%,大型交易费率递减
|
||||||
|
- 海外莱曼公式(Lehman Formula):累退费率,5000万人民币≈680万欧元→约15万欧元(约118万人民币),但中国市场费率通常高于莱曼
|
||||||
|
- 中国FA实操中常用"一口价"或协商费率,少见纯莱曼公式
|
||||||
|
|
||||||
|
## 交易协调人(律师兼任)收费参考
|
||||||
|
|
||||||
|
- 固定项目管理费:5万–10万/月,或每项目15万–30万
|
||||||
|
- 从FA成功费分成:10%–15%
|
||||||
|
- 最优组合:固定费(保底)+ FA分成(激励)+ 法律费独立收取(不混)
|
||||||
|
|
||||||
|
## 第三方机构管理原则
|
||||||
|
|
||||||
|
- FA负责整体协调,但不代替第三方出具报告
|
||||||
|
- 第三方费用由客户直接支付
|
||||||
|
- 各机构独立承担专业责任
|
||||||
|
- 律师(作为交易协调人)可帮FA管理第三方机构,但不能替第三方机构的工作成果背书
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# DOCX 批注(Word 原生 comment)插入 — 纯 zipfile+lxml
|
||||||
|
|
||||||
|
实战来源:南通新东方校外培训服务合同独立审查(2026-06-17)。Maggie 要求"用修订**和批注**的形式"。`ContractEditor` 没有批注方法(`dir()` 确认无 comment/annot/note),批注必须手写 OOXML。已验证可在 OnlyOffice 正常显示。
|
||||||
|
|
||||||
|
## 何时用 DOCX 批注 vs PDF 批注
|
||||||
|
- **docx 合同** → 用本文方法(Word 原生 comment,OnlyOffice 显示为右侧批注气泡)。
|
||||||
|
- **PDF 合同** → 用 pymupdf(fitz) 高亮+comment annotation(见 SKILL.md「PDF合同直接批注」)。两者不通用。
|
||||||
|
|
||||||
|
## 批注内容铁律(与 SKILL.md 一致,复述强调)
|
||||||
|
- 只写"建议……",给方案;**不写理由/原因/因为**;**不加【新增】【建议】等标签**。
|
||||||
|
- 批注仅限两类:①需客户确认(名称空白、标准未定义需明示);②建议增加条款且内容较长。**选择题/勾选项不处理(2026-07-08废止)。**
|
||||||
|
- 能直接修订的一律修订,批注是最后手段。
|
||||||
|
|
||||||
|
## 五个改动点(缺一不可,否则 Word 报"无法打开/需修复")
|
||||||
|
1. **新增 `word/comments.xml`**:定义每条批注的 id/author/date/initials + 内容。
|
||||||
|
2. **`word/document.xml`**:在锚点文本范围**前**插 `w:commentRangeStart`、**后**插 `w:commentRangeEnd` + 一个带 `w:commentReference` 的 run。
|
||||||
|
3. **`[Content_Types].xml`**:加 `Override` 声明 comments.xml 的 content-type。
|
||||||
|
4. **`word/_rels/document.xml.rels`**:加 `Relationship` 指向 comments.xml。
|
||||||
|
5. 三处 id(rangeStart/rangeEnd/commentReference)与 comments.xml 的 `w:comment/@w:id` **必须全部一致**。
|
||||||
|
|
||||||
|
## 锚点定位(关键陷阱)
|
||||||
|
- 锚点文本要按**接受修订后**的文本匹配(遍历 w:t 时**跳过 w:del 内的**),否则被删字符会让匹配错位。
|
||||||
|
- commentRangeStart 必须插在段落第一个 `w:r` **或 `w:ins`** 之前(不能只找 w:r——修订后段首可能是 ins)。
|
||||||
|
- 先验证每个锚点在全文**唯一命中**(命中数==1)再插,多处命中会挂错段落。
|
||||||
|
|
||||||
|
## 可复用代码
|
||||||
|
```python
|
||||||
|
import zipfile, io
|
||||||
|
from lxml import etree
|
||||||
|
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||||
|
Wq = '{' + W + '}'
|
||||||
|
DATE = "2026-06-17T10:00:00Z"
|
||||||
|
|
||||||
|
comments = [ # 站顾问单位立场,需确认/建议增加内容
|
||||||
|
{"id":"201","anchor":"甲方扣除相应服务费后","text":"建议在合同或退费管理制度中明确“服务费”的扣费比例或计算方式,并在签约时向乙方明示。"},
|
||||||
|
{"id":"202","anchor":"向甲方住所地人民法院提起诉讼","text":"本条约定甲方住所地法院管辖,建议签约时以加粗或单独提示方式向乙方说明,尽到格式条款提示义务。"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def build_comments_xml(comments):
|
||||||
|
p = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
||||||
|
f'<w:comments xmlns:w="{W}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">']
|
||||||
|
for c in comments:
|
||||||
|
p.append(f'<w:comment w:id="{c["id"]}" w:author="WB" w:date="{DATE}" w:initials="WB">')
|
||||||
|
# 批注文字字体随原文(本例宋体sz=18小一号),hint=eastAsia 必带
|
||||||
|
p.append('<w:p><w:r><w:rPr><w:rFonts w:ascii="宋体" w:hAnsi="宋体" w:eastAsia="宋体" w:cs="宋体" w:hint="eastAsia"/><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr>')
|
||||||
|
p.append(f'<w:t xml:space="preserve">{c["text"]}</w:t></w:r></w:p></w:comment>')
|
||||||
|
p.append('</w:comments>')
|
||||||
|
return ''.join(p)
|
||||||
|
|
||||||
|
comments_xml = build_comments_xml(comments)
|
||||||
|
with open('IN.docx','rb') as f: data=f.read()
|
||||||
|
bi, bo = io.BytesIO(data), io.BytesIO()
|
||||||
|
inserted = {c["id"]: False for c in comments}
|
||||||
|
with zipfile.ZipFile(bi) as zin, zipfile.ZipFile(bo,'w',zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for it in zin.infolist():
|
||||||
|
raw = zin.read(it.filename)
|
||||||
|
if it.filename == 'word/document.xml':
|
||||||
|
tree = etree.fromstring(raw); body = tree.find(f'{Wq}body')
|
||||||
|
for para in body.findall(f'.//{Wq}p'):
|
||||||
|
ptext = '' # 接受修订后文本:跳过 del
|
||||||
|
for t in para.findall(f'.//{Wq}t'):
|
||||||
|
if not any(a.tag==f'{Wq}del' for a in t.iterancestors()):
|
||||||
|
ptext += (t.text or '')
|
||||||
|
for c in comments:
|
||||||
|
if not inserted[c["id"]] and c["anchor"] in ptext:
|
||||||
|
cid = c["id"]
|
||||||
|
first = next((ch for ch in para if ch.tag in (f'{Wq}r',f'{Wq}ins')), None)
|
||||||
|
if first is None: continue
|
||||||
|
crs = etree.Element(f'{Wq}commentRangeStart'); crs.set(f'{Wq}id',cid); first.addprevious(crs)
|
||||||
|
cre = etree.Element(f'{Wq}commentRangeEnd'); cre.set(f'{Wq}id',cid); para.append(cre)
|
||||||
|
rr = etree.SubElement(para,f'{Wq}r'); rp=etree.SubElement(rr,f'{Wq}rPr')
|
||||||
|
rs = etree.SubElement(rp,f'{Wq}rStyle'); rs.set(f'{Wq}val','CommentReference')
|
||||||
|
cref = etree.SubElement(rr,f'{Wq}commentReference'); cref.set(f'{Wq}id',cid)
|
||||||
|
inserted[cid] = True
|
||||||
|
raw = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||||
|
elif it.filename == '[Content_Types].xml':
|
||||||
|
ct = etree.fromstring(raw); NS='http://schemas.openxmlformats.org/package/2006/content-types'
|
||||||
|
ov = etree.SubElement(ct,f'{{{NS}}}Override')
|
||||||
|
ov.set('PartName','/word/comments.xml')
|
||||||
|
ov.set('ContentType','application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml')
|
||||||
|
raw = etree.tostring(ct, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||||
|
elif it.filename == 'word/_rels/document.xml.rels':
|
||||||
|
rt = etree.fromstring(raw); RNS='http://schemas.openxmlformats.org/package/2006/relationships'
|
||||||
|
r = etree.SubElement(rt,f'{{{RNS}}}Relationship')
|
||||||
|
r.set('Id','rIdComments1')
|
||||||
|
r.set('Type','http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments')
|
||||||
|
r.set('Target','comments.xml')
|
||||||
|
raw = etree.tostring(rt, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||||
|
zout.writestr(it, raw)
|
||||||
|
zout.writestr('word/comments.xml', comments_xml.encode('utf-8'))
|
||||||
|
with open('OUT.docx','wb') as f: f.write(bo.getvalue())
|
||||||
|
assert all(inserted.values()), f"未全部挂靠: {inserted}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 交付前验证(缺一不可)
|
||||||
|
1. **id 四向一致**:`commentRangeStart` / `commentRangeEnd` / `commentReference` 三组 id 集合 == comments.xml 的 `w:comment/@w:id` 集合。
|
||||||
|
2. **python-docx 能打开**(XML 合法)。
|
||||||
|
3. **接受修订后锚点存在**:批注挂靠的文本在去 del 后仍在。
|
||||||
|
4. **OnlyOffice 渲染**(onlyoffice-render.sh)确认批注气泡正常显示,不破坏修订标记。
|
||||||
|
|
||||||
|
## 修订与批注可共存
|
||||||
|
同一份 docx 先用 ContractEditor 做完 tracked_replace/add_clause 并 save,再在产物上跑本脚本加批注。批注的 commentRangeStart 会落在修订后的段落结构里(段首可能是 w:ins),代码已用 `(w:r, w:ins)` 兼容。
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 检测已审查文件重复处理(Workflow产出双版本问题)
|
||||||
|
|
||||||
|
## 2026-07-13 消防设施检测合同教训
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
同一份合同在任务交付目录出现两个文件:
|
||||||
|
- v1: 有WB tracked changes(正确交付物)
|
||||||
|
- v2: 无tracked changes + 有WB批注(纯批注版)
|
||||||
|
|
||||||
|
### 诊断方法
|
||||||
|
```python
|
||||||
|
# 快速判断文件性质
|
||||||
|
with zipfile.ZipFile(filepath, 'r') as z:
|
||||||
|
doc_xml = z.read('word/document.xml')
|
||||||
|
# 检查tracked changes
|
||||||
|
ins_count = doc_xml.count(b'w:ins')
|
||||||
|
del_count = doc_xml.count(b'w:del')
|
||||||
|
# 检查批注
|
||||||
|
has_comments = 'word/comments.xml' in z.namelist()
|
||||||
|
if has_comments:
|
||||||
|
comments = z.read('word/comments.xml')
|
||||||
|
comment_count = comments.count(b'w:comment ')
|
||||||
|
|
||||||
|
print(f"INS: {ins_count}, DEL: {del_count}, Comments: {comment_count}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 判断标准
|
||||||
|
| 文件状态 | 性质 | 应否保留 |
|
||||||
|
|----------|------|----------|
|
||||||
|
| 有INS/DEL + 无comments | 标准修订版 | ✅ 正确交付物 |
|
||||||
|
| 有INS/DEL + 有comments | 修订+批注版 | ✅ 正确 |
|
||||||
|
| 无INS/DEL + 有comments | 纯批注版 | ⚠️ 需审查批注合规性 |
|
||||||
|
| 无INS/DEL + 无comments | 原文副本 | ❌ 不应在交付目录 |
|
||||||
|
|
||||||
|
### 纯批注版的审查要点
|
||||||
|
- 是否违反"能改就不批注"原则
|
||||||
|
- 批注立场是否正确(站甲方)
|
||||||
|
- 是否属于"提醒性批注"(禁止)
|
||||||
|
- **严重错误示例**:Comment 203建议"违约金偏高,建议设上限"——这是在帮乙方限制甲方的违约金权利,立场完全反了
|
||||||
|
|
||||||
|
### python-docx的.text陷阱
|
||||||
|
`paragraph.text`不反映批注内容。两份文件的`.text`可能100%相同但实际一份有6条批注。**判断文件是否相同必须检查comments.xml**。
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 文件版本管理纪律(2026-07-01 总结多次返工教训)
|
||||||
|
|
||||||
|
## 铁律:操作前备份,操作后验证,不覆盖不重做
|
||||||
|
|
||||||
|
### 1. 操作前必须备份
|
||||||
|
任何对 docx 文件的修改操作前,先 `cp` 一份到 `/tmp/contract-backup/` 并带时间戳:
|
||||||
|
```bash
|
||||||
|
cp /tmp/反委托_版本1.docx /tmp/contract-backup/反委托_版本1_$(date +%H%M).docx
|
||||||
|
```
|
||||||
|
|
||||||
|
2026-07-01教训:反委托代发工资协议做了7-8个版本,每次覆盖前一版,最终华诚-Z的修订痕迹差点不可恢复(在v1_doro_updated.docx中找到最后一份)。
|
||||||
|
|
||||||
|
### 2. 增量修复,不从头重做
|
||||||
|
出问题时修补当前版本,不从原文件重新做一遍。重做=覆盖=丢失中间状态。
|
||||||
|
|
||||||
|
### 3. 操作后验证完整性
|
||||||
|
每次修改 docx 后必须验证:
|
||||||
|
- comments.xml:批注数量、作者、ID 是否完整(与修改前对比)
|
||||||
|
- document.xml:tracked changes 的 author 集合是否正确
|
||||||
|
- 文件大小:是否合理(不应比修改前小太多)
|
||||||
|
|
||||||
|
### 4. 中间版本命名规范
|
||||||
|
```
|
||||||
|
反委托_版本1_v1.docx → 第一版
|
||||||
|
反委托_版本1_v2.docx → 第二版(不覆盖v1)
|
||||||
|
反委托_版本1_v3.docx → 第三版
|
||||||
|
反委托_版本1_final.docx → 确认后的最终版(覆盖上传到Nextcloud)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Subagent 输出必须验证
|
||||||
|
delegate_task 返回后:
|
||||||
|
- 检查 result.status 是否 "completed"
|
||||||
|
- 对文件类结果:用 zipfile 打开验证 comments/tracked changes 完整性
|
||||||
|
- 不能假设 subagent 正确——它可能丢批注、改错 author、漏条款
|
||||||
|
|
||||||
|
## 常见覆盖事故
|
||||||
|
|
||||||
|
| 事故 | 根因 | 预防 |
|
||||||
|
|------|------|------|
|
||||||
|
| 华诚-Z修订被全部改成WB | 多次重做时每次都"统一author=WB" | 备份原始含华诚-Z的版本 |
|
||||||
|
| 批注丢失(4条变2条) | 从头重建时没对比原文件的comments.xml | 修改后立即验证批注数量 |
|
||||||
|
| 字体覆盖(仿宋_GB2312→仿宋) | 重做时用了错误的字体名 | 从原文件克隆rPr,不手写 |
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Layering WB Revisions on High-Density Tracked Changes Documents
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
When a document already has extensive tracked changes from another author (e.g., 华诚-Z with 170+ INS and 90+ DEL), ContractEditor's `tracked_replace` frequently fails with `ValueError: Element is not a child of this node` because the paragraph structure is heavily fragmented with interleaved `w:ins`/`w:del`/`w:r` elements.
|
||||||
|
|
||||||
|
## Solution: Direct lxml Operations
|
||||||
|
|
||||||
|
### Strategy
|
||||||
|
Use zipfile + lxml to directly manipulate the XML instead of ContractEditor library. Three operation types:
|
||||||
|
|
||||||
|
### 1. Append text to existing paragraph end
|
||||||
|
Find the paragraph, locate the last content element, and append a `w:ins` after it.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Find the last non-pPr child element in the paragraph
|
||||||
|
last_content = None
|
||||||
|
for child in p:
|
||||||
|
if child.tag != f'{WNS}pPr':
|
||||||
|
last_content = child
|
||||||
|
|
||||||
|
# Create INS element
|
||||||
|
ins = etree.SubElement(p, f'{WNS}ins')
|
||||||
|
ins.set(f'{WNS}id', str(next_id))
|
||||||
|
ins.set(f'{WNS}author', 'WB')
|
||||||
|
ins.set(f'{WNS}date', '2026-07-02T00:00:00Z')
|
||||||
|
|
||||||
|
r = etree.SubElement(ins, f'{WNS}r')
|
||||||
|
# Clone rPr from nearby run
|
||||||
|
rpr = get_reference_rpr(p) # see below
|
||||||
|
if rpr is not None:
|
||||||
|
r.insert(0, copy.deepcopy(rpr))
|
||||||
|
|
||||||
|
t = etree.SubElement(r, f'{WNS}t')
|
||||||
|
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||||
|
t.text = "追加的文字内容"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Insert new paragraph (全段INS)
|
||||||
|
Clone neighboring paragraph's pPr, create a new `w:p` with all content inside `w:ins`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Clone pPr from reference paragraph
|
||||||
|
ref_p = paras[target_idx] # the paragraph after which to insert
|
||||||
|
new_p = etree.Element(f'{WNS}p')
|
||||||
|
|
||||||
|
# Clone pPr
|
||||||
|
ref_ppr = ref_p.find(f'{WNS}pPr')
|
||||||
|
if ref_ppr is not None:
|
||||||
|
new_p.append(copy.deepcopy(ref_ppr))
|
||||||
|
|
||||||
|
# Create INS wrapping all content
|
||||||
|
ins = etree.SubElement(new_p, f'{WNS}ins')
|
||||||
|
ins.set(f'{WNS}id', str(next_id))
|
||||||
|
ins.set(f'{WNS}author', 'WB')
|
||||||
|
ins.set(f'{WNS}date', '2026-07-02T00:00:00Z')
|
||||||
|
|
||||||
|
r = etree.SubElement(ins, f'{WNS}r')
|
||||||
|
rpr = get_reference_rpr(ref_p)
|
||||||
|
if rpr is not None:
|
||||||
|
r.insert(0, copy.deepcopy(rpr))
|
||||||
|
t = etree.SubElement(r, f'{WNS}t')
|
||||||
|
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||||
|
t.text = "新增条款全文"
|
||||||
|
|
||||||
|
# Insert after reference paragraph
|
||||||
|
ref_p.addnext(new_p)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Character-level replacement within high-density paragraph
|
||||||
|
When text to replace is inside an existing `w:ins` from another author (e.g., 华诚-Z), you need to split that ins element.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Find the ins element containing target text
|
||||||
|
for ins_elem in p.findall(f'{WNS}ins'):
|
||||||
|
for r in ins_elem.findall(f'{WNS}r'):
|
||||||
|
t = r.find(f'{WNS}t')
|
||||||
|
if t is not None and t.text and old_text in t.text:
|
||||||
|
# Split: keep text before, add WB del+ins for changed part, keep text after
|
||||||
|
pos = t.text.index(old_text)
|
||||||
|
before = t.text[:pos]
|
||||||
|
after = t.text[pos + len(old_text):]
|
||||||
|
|
||||||
|
# Modify existing t to keep only 'before'
|
||||||
|
t.text = before + after.replace(old_text, new_text) # simplified
|
||||||
|
# Or split into multiple elements...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting reference rPr
|
||||||
|
```python
|
||||||
|
def get_reference_rpr(p):
|
||||||
|
"""Get rPr from first non-del run in paragraph, or from 华诚-Z ins"""
|
||||||
|
# Try plain runs first
|
||||||
|
for r in p.findall(f'{WNS}r'):
|
||||||
|
rpr = r.find(f'{WNS}rPr')
|
||||||
|
if rpr is not None:
|
||||||
|
return rpr
|
||||||
|
# Try non-WB ins elements
|
||||||
|
for ins in p.findall(f'{WNS}ins'):
|
||||||
|
if ins.get(f'{WNS}author') != 'WB':
|
||||||
|
for r in ins.findall(f'{WNS}r'):
|
||||||
|
rpr = r.find(f'{WNS}rPr')
|
||||||
|
if rpr is not None:
|
||||||
|
return rpr
|
||||||
|
# Try previous paragraph
|
||||||
|
prev = p.getprevious()
|
||||||
|
if prev is not None:
|
||||||
|
return get_reference_rpr(prev)
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical: Post-save sz fix
|
||||||
|
|
||||||
|
When INS runs clone rPr from paragraphs that lack explicit `w:sz` (relying on style inheritance), the INS will render at wrong size. **Always run a post-save sweep:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Determine dominant body sz from neighboring paragraphs
|
||||||
|
# Then fix all WB INS runs missing sz
|
||||||
|
for ins in body.iter(f'{WNS}ins'):
|
||||||
|
if ins.get(f'{WNS}author') != 'WB':
|
||||||
|
continue
|
||||||
|
for r in ins.findall(f'{WNS}r'):
|
||||||
|
rpr = r.find(f'{WNS}rPr')
|
||||||
|
if rpr is not None:
|
||||||
|
sz = rpr.find(f'{WNS}sz')
|
||||||
|
if sz is None:
|
||||||
|
sz = etree.SubElement(rpr, f'{WNS}sz')
|
||||||
|
sz.set(f'{WNS}val', dominant_sz) # e.g., '24' for 12pt
|
||||||
|
szCs = etree.SubElement(rpr, f'{WNS}szCs')
|
||||||
|
szCs.set(f'{WNS}val', dominant_sz)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Author Unification
|
||||||
|
|
||||||
|
After Doro reviews and confirms, unify all authors to WB:
|
||||||
|
```bash
|
||||||
|
python scripts/unify-author-wb.py input.docx [output.docx]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lesson Learned (2026-07-02)
|
||||||
|
- Doro will edit the files in OnlyOffice after upload. Always download Doro's version before doing further work.
|
||||||
|
- "你自己要满意再给我" = self-verify before delivery, don't ask user to check.
|
||||||
|
- "认真做" = thoroughness signal. Read full contract text, verify each modification landed correctly.
|
||||||
|
- When Doro says "看看是否还有需要调整的" = compare your version vs Doro's, identify what Doro changed, assess if further work needed.
|
||||||
|
- Unifying author is a standard final step — use the script, don't hand-code each time.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: lawyer-letter-formatting
|
||||||
|
description: 律师函制作格式要点。基于Watson&Band模板,logo在正文段落anchor中而非header XML。
|
||||||
|
tags: [legal, lawyer-letter, docx, formatting]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 律师函制作
|
||||||
|
|
||||||
|
## 关键格式(参考_律师函模板)
|
||||||
|
- **字体**:仿宋 12pt,西文Times New Roman
|
||||||
|
- **首行缩进**:304800 EMU
|
||||||
|
- **行距**:1.25倍
|
||||||
|
- **对齐**:两端对齐(JUSTIFY)
|
||||||
|
- **列表编号**:numbering.xml中japaneseCounting格式(第一、第二、第三、)
|
||||||
|
- **送达信息**:9pt
|
||||||
|
|
||||||
|
## 关键陷阱
|
||||||
|
- **Logo不在header XML中**!是作为浮动锚点(anchor drawing)嵌在正文第一段落的run中
|
||||||
|
- 用python-docx重建段落会丢失drawing元素,必须从模板段落提取保留
|
||||||
|
- 复制模板时要保留原始段落的XML结构,不能只复制文字
|
||||||
|
|
||||||
|
## 参考文件位置
|
||||||
|
- 模板:Doro诉讼案件任务/参考文件/_律师函
|
||||||
|
|
||||||
|
## 交付位置
|
||||||
|
- 放到 Doro其他任务/交付文件/(不是待处理任务)
|
||||||
|
- 交付后@doro通知
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# 在「用户已自行修订过」的合同上叠加我方修订
|
||||||
|
|
||||||
|
实战来源:金信大厦5层东部租赁合同(2026-06-25)。Maggie 本人已用修订模式改了 6 处(author="maggie jia"),要求小Maggie 在此基础上**再补几处**(模版比对后补不可抗力对等、装修残值公式、抵押救济),**保留她的全部修订一字不动**。
|
||||||
|
|
||||||
|
## 何时用本配方
|
||||||
|
- 收到的 docx **已带 track changes**(settings.xml 有 `<w:trackRevisions/>`,文中有 author≠WB/小Maggie 的 w:ins/w:del)。
|
||||||
|
- 任务是**在用户既有修订之上追加几处**,不是重审、不是从干净稿做。
|
||||||
|
- **不重跑 workflow,也不用 ContractEditor 库**——库的字符级 diff 引擎会把用户既有 w:ins/w:del 卷进来重算,破坏其修订。一律 zipfile+lxml 直接追加节点。
|
||||||
|
|
||||||
|
## 五步配方
|
||||||
|
|
||||||
|
### 1. 新修订 id 从 `maxid+1000` 起,防撞 + 便于事后过滤
|
||||||
|
```python
|
||||||
|
maxid = 0
|
||||||
|
for el in root.iter():
|
||||||
|
if el.tag in (Wq+"ins", Wq+"del"):
|
||||||
|
v = el.get(Wq+"id")
|
||||||
|
if v and v.isdigit(): maxid = max(maxid, int(v))
|
||||||
|
nextid = [maxid + 1000] # 1000 间隔:本次新增 id 全 >1000,过滤/核验时一眼区分
|
||||||
|
def newid(): nextid[0]+=1; return str(nextid[0])
|
||||||
|
```
|
||||||
|
为什么 +1000 不是 +1:核验「我的修订」与「用户的修订」时,`int(id)>1000` 直接切分两批,不必记具体数字。
|
||||||
|
|
||||||
|
### 2. 作者:沿用文档既有修订线,不强行套 WB
|
||||||
|
金信大厦案文档既有修订 author="maggie jia",本次追加**沿用同一 author**(保持修订线一致、Maggie 看就是「她那条线的延续」)。
|
||||||
|
> 注意与「author 铁律=WB」的边界:WB 是 Doro 体系合同审查的署名;当**文档已有用户自己的修订线**、任务是「在她的修订上接着改」时,沿用她的 author 让修订归并到同一作者更自然。归属按文档既有线定,不是无脑套 WB。拿不准就问。
|
||||||
|
|
||||||
|
### 3. rPr:克隆「用户已渲染正确的 INS」当样板,预防中文字体回退坑
|
||||||
|
不要自己造 rPr。找一个用户已有的、**中文显示正常的** w:ins run,读它的 rPr 当模板:
|
||||||
|
```python
|
||||||
|
# 金信大厦案模板:<w:rFonts w:ascii="Times New Roman" w:eastAsiaTheme="minorEastAsia"
|
||||||
|
# w:hAnsi="Times New Roman" w:cs="Times New Roman" w:hint="eastAsia"/>
|
||||||
|
# <w:sz w:val="21"/><w:szCs w:val="21"/>
|
||||||
|
def make_rpr():
|
||||||
|
rpr = etree.Element(Wq+"rPr")
|
||||||
|
rf = etree.SubElement(rpr, Wq+"rFonts")
|
||||||
|
rf.set(Wq+"ascii","Times New Roman"); rf.set(Wq+"eastAsiaTheme","minorEastAsia")
|
||||||
|
rf.set(Wq+"hAnsi","Times New Roman"); rf.set(Wq+"cs","Times New Roman"); rf.set(Wq+"hint","eastAsia")
|
||||||
|
sz = etree.SubElement(rpr, Wq+"sz"); sz.set(Wq+"val","21")
|
||||||
|
etree.SubElement(rpr, Wq+"szCs").set(Wq+"val","21")
|
||||||
|
return rpr
|
||||||
|
```
|
||||||
|
`eastAsiaTheme="minorEastAsia"+hint="eastAsia"` 让中文走主题回退(金信大厦回退到宋体),西文 Times New Roman——这是这类合同 INS 中文正常显示的关键,详见 SKILL.md「INS 中文字体」节。
|
||||||
|
|
||||||
|
### 4. 三种插入机制(按改动类型选)
|
||||||
|
- **纯追加**(句末补一句救济/公式):定位段落最后一个 normal run / 最后一个 ins,`last.addnext(make_ins(text, rpr))`。
|
||||||
|
- **删一段换对等表述**(单向条款改双向):split 原 run → 原 run 文本保留前半、`addnext(make_del(后半, 原rpr))` → 再 `del.addnext(make_ins(对等表述, rpr))`。
|
||||||
|
- **替换数值/词**(6→12 个月、percent→百分之):字符级定位,DEL 旧 + INS 新。
|
||||||
|
|
||||||
|
`make_del` 用 `<w:del><w:r><w:delText>`、克隆原 run 的 rPr 加 `rsidDel`;`make_ins` 用 `<w:ins><w:r><w:t xml:space="preserve">`。
|
||||||
|
|
||||||
|
### 5. 只换 document.xml(settings 已开 trackRevisions 就不动它)
|
||||||
|
```python
|
||||||
|
with zipfile.ZipFile(SRC) as zin, zipfile.ZipFile(tmp,"w",zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for it in zin.namelist():
|
||||||
|
zout.writestr(it, new_doc if it=="word/document.xml" else zin.read(it))
|
||||||
|
```
|
||||||
|
|
||||||
|
## 四查验证(缺一不可)
|
||||||
|
1. **python-docx 能打开**(`Document(out)`)——XML 合法。
|
||||||
|
2. **接受所有修订后文本正确**——抽出「保留 ins 内容、丢弃 del 内容」的纯文本,逐处核我改的几段语句通顺、内容对。
|
||||||
|
3. **本次新增 INS(id>1000)含中文 run 字体非 Times New Roman**——`eastAsiaTheme=="minorEastAsia" or (ea and ea!="Times New Roman")` 应全真。
|
||||||
|
4. **🔴 用户原有修订逐 id 比对一字未动**——把源文件与产物里 `id<=maxid` 的所有 ins/del 提成 `(id, tag, author, 文本)` 排序比对,必须**完全相等**。这是本配方的核心安全验证:证明我只追加、没碰用户的任何一处。
|
||||||
|
|
||||||
|
## 收口(与库路径相同)
|
||||||
|
`scripts/accept-revisions-preview.py` 生成干净版 → OnlyOffice 渲染 → vision 视觉验收。
|
||||||
|
- **vision 报「页底某句截断」先分清 PDF 分页 vs 真丢数据**:金信大厦案 vision 报抵押救济句在 P7 底部截断,实为该句跨页接到 P8 开头——①数据层读该 INS 完整内容在;②P7+P8 拍平后 grep 完整句存在 → 确认是 PDF 分页跨页,OnlyOffice 滚动查看正常,**不返工**。判据同 contract-portfolio-analysis Pitfall:数据层完整+跨页搜得到=分页现象。
|
||||||
|
- vision 对字体/‰%/小符号的误判同样适用——回数据层核,别据像素返工(见 SKILL.md「交付前视觉验收的两个已知误判」)。
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 法律文书:脚注、同源模板成稿、Doro 编辑后字体修复
|
||||||
|
|
||||||
|
contract-editor 库(zipfile+lxml)在**新成稿文书**(非修订态)上的复用。2026-06-23-24 邹家《情况反映》制作中验证。配套 `litigation-doc-tracked-changes.md`(那篇讲修订态;本篇讲脚注+新建文书+字体规范化,都不是 tracked-changes)。
|
||||||
|
|
||||||
|
## 1. 法条原文脚注——从同案"姊妹文书"克隆脚注样式(铁律:脚注样式不要凭空造)
|
||||||
|
|
||||||
|
需求场景:Doro 把正文里的法条引用("《民事诉讼法》第七十一条之规定")要求改成**脚注呈现法条全文**,且"脚注格式和申请书一样"。
|
||||||
|
|
||||||
|
正确做法是从**同案已有带脚注的文书**(如同目录的《民事诉讼监督申请书》v9)克隆脚注体例,而不是自己拼 footnotes.xml:
|
||||||
|
|
||||||
|
**脚注的两个组成**(先从姊妹文书读出样式模板):
|
||||||
|
- 正文里的**引用标**:一个 `<w:r>`,rPr 带 `<w:rStyle w:val="affb"/>` + Times New Roman + 与正文同字号(sz24),内含 `<w:footnoteReference w:id="N"/>`。`affb` 是 Word 默认的 FootnoteReference 字符样式 id(不同文档可能不同,**从姊妹文书正文的 footnoteReference 承载 run 实测,别硬编码**)。
|
||||||
|
- footnotes.xml 里的**脚注正文**:separator/continuationSeparator 两个特殊脚注(id=-1/0,原样复制)+ 内容脚注(id≥1)。内容脚注段落 spacing line=240,run 字号是**脚注体例 sz18(9pt,小于正文)**,法名加粗(`<w:b/>`)、条号与原文不加粗。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 从姊妹文书 sqs_v9.docx 取模板
|
||||||
|
fn_sqs = etree.fromstring(z_sqs.read('word/footnotes.xml'))
|
||||||
|
special = {ft: deepcopy(f) for f in fn_sqs.iter(qn('footnote'))
|
||||||
|
for ft in [f.get(qn('type'))] if ft in ('separator','continuationSeparator')}
|
||||||
|
content_tmpl_p = deepcopy([f.find(qn('p')) for f in fn_sqs.iter(qn('footnote'))
|
||||||
|
if f.get(qn('id'))=='1'][0])
|
||||||
|
# 从模板段落抽三种 rPr:mark(带rStyle affb)、bold(法名)、plain(原文)
|
||||||
|
# 正文引用标 rPr 则从姊妹文书 document.xml 里 footnoteReference 承载 run 抓
|
||||||
|
```
|
||||||
|
|
||||||
|
**目标 docx 必须已支持脚注**:`word/_rels/document.xml.rels` 有 footnotes 关系、`[Content_Types].xml` 有 `footnotes+xml`、styles.xml 有 `affb` 样式。若目标是从同源文书演化来的(本例情况反映以申请书为母版),这三样天然齐全;若从零新建则要补。
|
||||||
|
|
||||||
|
## 2. 脚注标定位的坑:锚点跨 run 时 footnoteReference 会插错位置(本会话实犯)
|
||||||
|
|
||||||
|
把脚注标插在"第五十一条第二款"之后时,第一版用"找锚点子串→定位锚点所在 run→run 后插标",结果 ³ 插到了下游的"承办部门"后面——因为锚点文字**跨多个 run**,按 run 粒度定位会落到错误的 run。
|
||||||
|
|
||||||
|
**正解:字符流定位 + 必要时拆 run**。把全段所有 `w:t` 拼成字符流,建立 `每个字符→(t元素, 字符在t内的索引)` 映射,找到锚点**结束字符**的精确位置;若结束字符在某 run 中间,**split 该 run**(head 留原 run,tail 进新 run),脚注引用 run 插在 head 和 tail 之间。这样标精确落在"…第二款【标】所定…"。
|
||||||
|
|
||||||
|
验证只能靠 OnlyOffice x2t 渲染后看页脚——vision 一眼就抓出"³ 标在承办部门后",肉眼读 XML 容易漏。体例统一:四个脚注一律"法条号正后方"挂注(不要有的挂句末有的挂条号后)。
|
||||||
|
|
||||||
|
## 3. 用同案文书做"母版"新建文书——保证两份同源同体例
|
||||||
|
|
||||||
|
新建《情况反映》时,以同案《民事诉讼监督申请书》v9 为母版克隆,确保字体/字号/页边距/样式完全一致(Doro/Maggie 两份并排看不会有体例差):
|
||||||
|
- 从母版抽各类段落模板:title(居中bold sz30)、body(首行缩进480 sz24)、recip(顶格bold 机关名)、sign(右对齐)、date、attachment-title、attachment-item。`mk(tmpl_key, text, bold=, no_indent=)` 克隆模板段→清空 run/numPr/ins/del→重设仿宋+Times→填文字。
|
||||||
|
- 用母版的整个 docx 做容器(保留 sectPr 页面设置、styles、numbering),只重写 body 的段落序列 + 清掉 `<w:trackRevisions/>`。
|
||||||
|
- **清掉母版页眉**:母版页眉可能是另一种文书的抬头(本例申请书页眉"申请监督案号/受理法院"套在情况反映上不对)。清页眉要两步:①删页眉段所有 run 文字;②**删页眉段 pPr 的 `<w:pBdr>`**(页眉那条横线来自段落下边框,只删文字会留一条孤线)。OnlyOffice 渲染确认顶部纯白到标题。
|
||||||
|
|
||||||
|
## 4. ⚠️Doro 用编辑器改过的 docx 会丢显式 eastAsia 字体属性——每轮都要补(本会话两轮各犯一次)
|
||||||
|
|
||||||
|
**现象**:Doro 在他本机编辑器改过 docx 后回传,正文中文 run 的 `rFonts` **没有显式 eastAsia 字体名**(本会话两轮分别 1283、1243 个中文字符 `eastAsia=None`,docDefaults 也空)。OnlyOffice 靠底层回退仍渲染成仿宋、肉眼看正常,但**显式字体属性缺失不符合交付标准**(我们要求中文显式仿宋)。
|
||||||
|
|
||||||
|
**判别**:交付前扫一遍——
|
||||||
|
```python
|
||||||
|
for r in root.iter(qn('r')):
|
||||||
|
rf = r.find(qn('rPr/rFonts'))
|
||||||
|
ea = rf.get(qn('eastAsia')) if rf is not None else None
|
||||||
|
# 统计含中文 run 里 ea is None 的数量;>0 就要补
|
||||||
|
```
|
||||||
|
|
||||||
|
**修复(格式规范化,不改字形/文字/Doro 内容)**:每个含文字的 run,rFonts 显式设 `eastAsia=仿宋`,缺 ascii/hAnsi 的补 `Times New Roman`;再给 `docDefaults/rPrDefault/rPr/rFonts` 补 `eastAsia=仿宋` 兜底。改完目标:CJK 全仿宋、英数全 Times。**这是和合同字体规范化同类的操作,但要点在"每次 Doro 回传都要重做一遍"**——他的编辑器每改一次就再剥一次,不是一次性问题。改完必须 OnlyOffice 重渲染确认无字形回退(字体属性动过就要重验)。
|
||||||
|
|
||||||
|
## 5. 附件/正文 Doro 自己加的内容:修错别字但不擅改实质
|
||||||
|
|
||||||
|
Doro 自己在附件加了"检查监督申请书"——①错别字"检**查**"→"检**察**"院的监督,规范名应是与正式文件名一致的《民事诉讼监督申请书》(改);②但若附件项之间有实质区分缺失(如两份《质证通知书》一份标了"3日期限版"另一份没标"15日期限版"),那是 Doro 定的内容,**只提示不擅改**。附件清单常是自动编号(numId),Doro 删手敲序号是对的,自动会续 1-6。
|
||||||
|
|
||||||
|
## 一句话
|
||||||
|
脚注从同案姊妹文书克隆样式(rStyle affb + sz18 脚注体、法名加粗)、标位置用字符流+拆run精确落在条号后;新建文书拿同案文书做母版保同源(清页眉含删 pBdr);**Doro 编辑器回传的 docx 每轮都丢显式 eastAsia,每次交付前都要全局补仿宋再渲染**。
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# 诉讼文书:法条原文脚注 + 母版克隆建新文书 + 字体规范化
|
||||||
|
|
||||||
|
contract-editor 库在诉讼文书上的三组技法,2026-06-23/24 邹家「情况反映」(给法院监督部门的程序违法反映材料)制作中验证,全部 OnlyOffice x2t 渲染逐页核对过。与 `litigation-doc-tracked-changes.md`(修订态技法)互补——本文是**脚注 + 新建文书 + 字体**层面。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、法条原文脚注(Doro 偏好:引用法律规定一律用脚注呈现原文,不删改不概括)
|
||||||
|
|
||||||
|
Doro 对引用法条的文书要求:**法条原文(一字不改、不归纳)用脚注方式写进去**。监督申请书 v9 已是这个体例,情况反映照搬。这是可复用的整套做法。
|
||||||
|
|
||||||
|
### 1. 脚注格式从同族已有文书克隆,不自造
|
||||||
|
申请书 v9 的 `word/footnotes.xml` 是现成模板。提取三样:
|
||||||
|
- **两个特殊脚注** `type=separator` / `type=continuationSeparator`(id=-1/0)——分隔线,照搬。
|
||||||
|
- **一个内容脚注的段落骨架**(id=1 的 `<w:p>`)——拿它的 `pPr`(脚注段 `spacing line=240`)和三种 run 的 `rPr` 模板:
|
||||||
|
- **mark rPr**:带 `<w:rStyle w:val="affb"/>` + Times New Roman + **sz18**(9pt 脚注体,不是正文 sz24)——脚注区那个序号。
|
||||||
|
- **bold rPr**:`<w:b/>` + sz18——法名加粗用。
|
||||||
|
- **plain rPr**:sz18 无 rStyle 无 b——条号+原文用。
|
||||||
|
- 正文里的脚注引用标(`footnoteReference` 承载 run)的 rPr 另取:从 v9 **正文** 里找 `r/footnoteReference` 那个 run 的 rPr(`rStyle=affb` + Times + **sz24**,跟正文同号,上标由 affb 样式控制)。
|
||||||
|
|
||||||
|
每条脚注 `<w:footnote id=N>` 段落结构:`[footnoteRef(mark rPr)][空格(plain)][法名(bold rPr)][条号+原文(plain rPr)]`。条号与原文之间用**全角空格**(如「第七十一条 证据应当…」)。
|
||||||
|
|
||||||
|
### 2. 目标 docx 已支持脚注则零配置
|
||||||
|
情况反映是从 v9 编辑来的,本就带 `word/footnotes.xml`、rels 里有 footnotes 关系、`[Content_Types].xml` 有 `footnotes+xml`、styles.xml 有 `affb` 样式——直接覆盖 footnotes.xml + 在 document.xml 插引用标即可,**不用补 rels/CT/style**。动手前先 grep 确认这四样齐全;若是从无脚注的 docx 起步,才需要补全四处。
|
||||||
|
|
||||||
|
### 3. 插入引用标的位置铁律 + 多 run 锚点陷阱(本次踩坑)
|
||||||
|
脚注上标要紧贴**法条号正后方**(如「第七十一条¹之规定」「第五十一条第二款³所定」),不要落在句末或下游词上。统一体例:四个脚注全部「条号后挂注」最整齐。
|
||||||
|
|
||||||
|
**陷阱**:`第五十一条第二款` 这种锚点在 docx XML 里常**跨多个 w:r**(编号、款号被拆在不同 run)。若按"找到 anchor 所在 run、在该 run 后插引用"的粗定位,会把上标插到 anchor **下游某个 run 后**——本次 ³ 错插到了「承办部门」后(隔了好几个词)。OnlyOffice 渲染出来才发现,vision 核对抓到的。
|
||||||
|
|
||||||
|
**正解:字符流 + split run 精确定位**:
|
||||||
|
```python
|
||||||
|
# 1) 拼接段落所有 w:t 成 full,建 map: full每个字符 -> (t_element, idx_in_t)
|
||||||
|
# 2) end = full.find(anchor) + len(anchor) - 1 # 锚点最后一个字符
|
||||||
|
# 3) t_end, k_end = map[end];把 t_end 文本 split:head=s[:k_end+1], tail=s[k_end+1:]
|
||||||
|
# 4) t_end.text=head;在 t_end 所在 run 之后 addnext 一个新 run(脚注引用);
|
||||||
|
# 若 tail 非空,再 addnext 一个同 rPr 的 run 承载 tail
|
||||||
|
```
|
||||||
|
这样上标精确落在锚点最后一字之后,不受 run 边界影响。容错:`第五十一条第二款` 找不到时退化找 `第五十一条`。
|
||||||
|
|
||||||
|
### 4. 款数存疑时,脚注放全条原文
|
||||||
|
Doro 引「第五十一条**第二款**」,但权威原文里"普通程序不少于十五日"实际在**第一款**。**不擅改他的款数**——脚注内容放该条**全文(含两款)**,无论款数对错,原文都完整覆盖、不断章;款数是否要改回原文里报给 Doro 定,不自己动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、母版克隆建新诉讼文书(保证与同案既有文书同源)
|
||||||
|
|
||||||
|
新建一份配套文书(情况反映 vs 已有的监督申请书),要让字体/字号/页边距/样式与同案既有文书**完全同源**——直接拿那份已交付的 docx 当母版。
|
||||||
|
|
||||||
|
- **段落模板克隆**:从母版 body 抓代表性段落各一份 deepcopy 当模板——title(居中 bold sz30)、body(首行缩进 fl480 sz24)、recip(机关名顶格 bold sz24)、sign(右对齐 sz24)、date(右对齐)、attt(附件标题顶格 bold)、att(附件项 fl480)。`mk(模板, 文本, bold, no_indent)`:克隆模板→清空其 run/ins/del→(按需删 numPr/ind)→`force_font`(eastAsia=仿宋, ascii/hAnsi=Times)→写新 run。
|
||||||
|
- **清空原 body 段落**,把新段落 insert 到 `sectPr` 之前(保页边距/分节设置不变)。
|
||||||
|
- **关 trackRevisions**:新建文书是全新成稿、非修订态,settings.xml 删 `<w:trackRevisions/>`。
|
||||||
|
- **页眉错配必须清**(本次踩坑):母版(监督申请书)的 `header1.xml` 带"申请监督民事诉讼案号/受理法院"这种**本文书类型专属抬头**,套到情况反映上不对路。处理:清空 header 所有 run 的文字。
|
||||||
|
- **页眉横线 = pBdr,单清文字不够**:清了页眉文字后 OnlyOffice 仍渲出一条横线——来自页眉段落的 `<w:pBdr>`(段落下边框)。遍历 header 所有 `pPr` 删 `pBdr`(本例 2 段),并去掉可能带边框的 `pStyle` 引用。styles.xml 里的 Header 样式若也挂 pBdr 一并清。重渲确认顶部纯白到标题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、字体规范化:源文档丢了显式 eastAsia 字体
|
||||||
|
|
||||||
|
**症状**:用户编辑过的 docx,正文中文 run 的 `rFonts` **没有 eastAsia 属性**(eastAsia=None),docDefaults 也没设。OnlyOffice 靠底层回退仍渲成仿宋,但**显式字体属性缺失**不符合"中文必须显式仿宋"的交付标准。本次 Doro 改的情况反映 1283 个中文字符全是 eastAsia=None。
|
||||||
|
|
||||||
|
**判断边界(重要)**:先比对**用户原版**——若原版本就是 eastAsia=None(不是你的编辑引入的),补齐属于**格式规范化(不改字形、不改一个文字、不动他的内容编辑)**,与历史上的字体规范化同类,可做。若是你的操作把字体搞丢的,那是 bug 要修源头。
|
||||||
|
|
||||||
|
**修法**:遍历所有含文字的 run,`rFonts` 设 `eastAsia=仿宋`,缺 ascii/hAnsi 则补 Times New Roman;再给 `docDefaults/rPrDefault/rPr/rFonts` 补 eastAsia=仿宋 兜底。改完 OnlyOffice **重渲**确认无字形回退(字体改动必重渲,vision 核"全文仿宋、无方框、无回退乱码")。核验:zipfile 统计 CJK→仿宋、LATIN→Times New Roman 计数全覆盖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收三件套(脚注版)
|
||||||
|
1. **正文脚注引用数** == 预期(`sum(r.find(footnoteReference) for r in runs)`)。
|
||||||
|
2. **footnotes.xml 内容数** == 引用数,逐条 print 前 50 字核法名+条号+原文。
|
||||||
|
3. **OnlyOffice x2t 渲染逐页 vision 核**:每个上标在**正确法条号正后方**(重点查多 run 锚点那条没错位)、页脚脚注区原文完整无截断、脚注字号 < 正文、法名加粗、无乱码。脚注主要落在前两页,逐页都要看。
|
||||||
|
|
||||||
|
## 一句话
|
||||||
|
法条脚注:格式克隆同族文书的 footnotes.xml(separator+内容模板,mark/bold/plain 三 rPr),引用标用 split-run 精确插在条号后(多 run 锚点必踩坑),款数存疑放全条原文不擅改。建新文书:克隆母版段落模板保同源,清错配页眉+pBdr 横线。字体:源档丢 eastAsia 时补齐属于规范化(先确认是原档状态不是自己搞丢的),改完必重渲。
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# 诉讼文书的修订态技法(contract-editor 库在合同以外文书上的复用)
|
||||||
|
|
||||||
|
ContractEditor 库不止用于合同——审/改诉讼文书(监督申请书、起诉状、答辩状等)同样适用。本文记录 2026-06-23 邹家民事诉讼监督申请书审改中验证过的几招,都用 OnlyOffice x2t(Doro/Maggie 实际引擎)渲染核对过。
|
||||||
|
|
||||||
|
## 1. 大段改写用「整块 del+ins」,不用字符级 diff(markup 可读性铁律)
|
||||||
|
`tracked_replace` 是字符级 diff(CJK 每字一 token + difflib)——**补字/小改**(错别字、补一个"在"字、称谓换词)用它,markup 干净。
|
||||||
|
但**大段改写**(整句重写、换论证)若用字符级 diff,新旧文本大量字符重合,markup 会交错成一团("未经~~及~~法庭审理""一百二十八条~~切~~国家机关"),Doro 在 OnlyOffice 看修订态根本读不下去。**接受修订后的最终文本虽正确,但修订态不可读 = 不合格交付**(Doro 有格式洁癖,看的就是 markup)。
|
||||||
|
- **正解**:对整句/整段改写,做「整块删 + 整块插」——`[<w:del>旧整句</w:del>][<w:ins>新整句</w:ins>]`,markup 显示为一条删除线旧句紧跟一条下划线新句,清清楚楚。
|
||||||
|
- 实现:复制 `tracked_replace` 的定位逻辑,但不跑 difflib,直接 `_mk_del(old_text)` + `_mk_ins(new_text)` 整块插。判据:**新旧文本相似度高、改动跨度大 → 整块;纯增删几个字 → 字符级**。
|
||||||
|
|
||||||
|
## 2. 称谓/词替换也要整词块替换,别让共享字符碎裂
|
||||||
|
把"法**庭**"改"莲都法**院**"时,"法"字共享,字符级 diff 会渲染成"莲都法~~庭~~院"(接受后对,markup 脏)。
|
||||||
|
- **正解**:整词 `tracked_block_replace("本案法庭向申请人送达", "莲都法院向申请人送达")` → markup 是干净的[删旧短语][插新短语]。
|
||||||
|
- 同理坑:替换前先分类全文每处目标词——actor 指代(要改)vs 法条/术语原文(如"法庭审理""在法庭上出示"=不能动)。grep 出所有命中,逐个判,别一刀切 replace_all。
|
||||||
|
|
||||||
|
## 3. 整段删除(让自动编号重排)——库没有,需自加 `tracked_delete_paragraph`
|
||||||
|
合并两个自动编号请求项(删一项、后项自动续号)时,要的是**段落级修订删除**:段内每个 run 包进 `<w:del>`,**且段落标记也要标删**——在 `pPr/rPr` 里插一个 `<w:del>`。这样接受修订后整段连段落标记一起消失,自动编号从 一二三四 重排成 一二三。
|
||||||
|
```python
|
||||||
|
def tracked_delete_paragraph(self, search_text):
|
||||||
|
p = self.find_para(search_text)
|
||||||
|
for r in list(p.findall(qn('r'))):
|
||||||
|
# 每个run的w:t搬进新建<w:del><w:r><w:delText>
|
||||||
|
...
|
||||||
|
ppr = p.find(qn('pPr')) or 新建
|
||||||
|
rpr = ppr.find(qn('rPr')) or 新建
|
||||||
|
rpr.insert(0, <w:del author=... date=...>) # 段落标记删除标记
|
||||||
|
```
|
||||||
|
缺了"段落标记删除"那一步,接受后会残留一个空的编号项。
|
||||||
|
|
||||||
|
## 4. 半角括号→全角:改 numbering.xml 的 lvlText,一次性根治
|
||||||
|
子标题 `(一)(二)…` 是自动编号时,半角括号来自 `word/numbering.xml` 里 `<w:lvlText w:val="(%1)">`。逐段改文档没用(那是渲染出来的)。
|
||||||
|
- **正解**:遍历 numbering.xml 所有 `<w:lvlText>`,`val` 里的 `(`→`(`、`)`→`)`,一次改全文所有同源编号。本例 37 处 lvlText 一次改完。
|
||||||
|
- 注意只动含 `()` 的 lvlText,`、`分隔的(如请求"一、二、三"用 `%1、`)不受影响。
|
||||||
|
|
||||||
|
## 5. 引号"统一为仿宋全角"——根因是引号 run 的字体不是中文字体
|
||||||
|
现象:正文中文是仿宋(继承样式),但弯引号 `“”`(U+201C/U+201D) 的 run `ascii=Times New Roman, eastAsia=None`。因为弯引号是**中西文模糊字符**,OnlyOffice 对没有 eastAsia 设定的字符按 ascii 字体渲染 → 引号显示成西文 Times 的粗重样式,和仿宋正文不协调。
|
||||||
|
- **正解(彻底版)**:对**纯引号/中文 run**,把 rFonts 的 `ascii/eastAsia/hAnsi/cs` 全设为「仿宋」+ `hint="eastAsia"`,消除歧义。对**引号+数字混排 run**(如 `“2026…`),按字符**拆 run**:引号段走仿宋、数字段保留 Times New Roman。
|
||||||
|
- 只设 eastAsia 不够稳——某些渲染下仍可能按 ascii 走 Times。纯引号 run 连 ascii 一起设仿宋最保险(数字 run 才需要保留 Times)。
|
||||||
|
- 核对:OnlyOffice x2t 渲染后裁剪含引号区域,确认引号纤细、与仿宋协调(不是又粗又重的衬线引号)。
|
||||||
|
|
||||||
|
## 6. 验证三件套(同合同终审,文书一样适用)
|
||||||
|
- `ed.validate()` 返回空。**注意**:诉讼文书的「请求项」原文常是加粗的(与合同正文不加粗体例不同),validate 的"不应加粗"规则会**误报**——先读原文该段普通 run 的 `<w:b>` 状态,若原文请求项本就加粗、INS 继承同样加粗=格式一致=误报,可放行。
|
||||||
|
- 逐个 `w:ins` 核 author 正确(诉讼文书署当前文书归属人,如本例 Doro 文书上署"小Maggie"修订;合同历史署"WB"——按文书归属定)、eastAsia 字体不缺。
|
||||||
|
- OnlyOffice x2t 渲两版:**修订态**(看 markup 干净)+ **接受态**(删 w:del、解包 w:ins、删段落标记被删的空段后重渲,看编号连续、全角括号生效、无乱码)。接受态自己生成:解包所有 ins、删所有 del、删 numPr 空段。
|
||||||
|
|
||||||
|
## 一句话
|
||||||
|
合同库的修订能力对所有 docx 文书通用;诉讼文书审改的差异点是:①大改写要整块 del+ins 保 markup 可读 ②引号/括号这类「字体/编号源」问题改 styles/numbering 层不改文档层 ③validate 加粗规则对加粗请求项会误报。
|
||||||
|
|
||||||
|
## 7. 接受所有修订 → 干净版 docx(反向操作,2026-06-24 徐函任务验证)
|
||||||
|
用户给一份**带修订痕迹+批注**的 docx,要「先接受现有修订、让我看干净版本」时——不是用 Word 手点"接受全部",用 zipfile+lxml 一次处理:
|
||||||
|
```python
|
||||||
|
W='http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||||
|
def w(t): return f'{{{W}}}'+t
|
||||||
|
# 1) w:del → 整个元素删掉(连 delText 一起没)
|
||||||
|
for d in root.findall('.//'+w('del')): d.getparent().remove(d)
|
||||||
|
# 2) w:ins → 解包:用其子元素替换它本身(保留插入内容,去掉ins包裹)
|
||||||
|
for ins in root.findall('.//'+w('ins')):
|
||||||
|
parent=ins.getparent(); idx=list(parent).index(ins)
|
||||||
|
for child in reversed(list(ins)): parent.insert(idx, child)
|
||||||
|
parent.remove(ins)
|
||||||
|
# 3) 属性变更追踪一并清:pPrChange/rPrChange/sectPrChange/tblPrChange/tcPrChange/trPrChange
|
||||||
|
for tag in ('pPrChange','rPrChange','sectPrChange','tblPrChange','tcPrChange','trPrChange'):
|
||||||
|
for el in root.findall('.//'+w(tag)): el.getparent().remove(el)
|
||||||
|
# 4) 批注三处一起拆(用户要"干净版"= 连批注也清):
|
||||||
|
# document.xml: 删 commentRangeStart/End,删含 commentReference 的整个 run
|
||||||
|
# settings.xml: 删 <w:trackRevisions/>(让文件退出跟踪模式)
|
||||||
|
# 打包时跳过 word/comments*.xml,并从 [Content_Types].xml 和 document.xml.rels 删 comments 的 Override/Relationship
|
||||||
|
```
|
||||||
|
要点:
|
||||||
|
- **w:del 删整块、w:ins 解包**——方向别搞反(del 是要丢弃的,ins 是要保留的)。
|
||||||
|
- **务必清 settings.xml 的 trackRevisions**,否则文件仍处于"跟踪修订"模式,用户继续编辑会又开始记修订。
|
||||||
|
- **批注要三处协同删**(comments.xml 本体 + document.xml 的 range/reference 锚点 + Content_Types/rels 注册),漏一处 OnlyOffice/Word 打开可能报损坏。
|
||||||
|
- 验证:解包后 `root.findall('.//w:ins')`/`w:del`/`w:commentReference` 全为 0;`'word/comments.xml' in zip.namelist()` 为 False;python-docx 能打开;OnlyOffice x2t 渲染核对无修订痕迹无批注无错位。
|
||||||
|
- vision 核干净版时顺带抓**残留内部标记**:黄色高亮(内部校对标记)、留白占位(编号"第 号"、日期" 日")、标题英文双连字符`--`应为中文破折号`——`——这些不是修订痕迹但属"未清的内部审核稿"特征,正式交付前要清。
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# lxml XML Declaration Fix for docx Files
|
||||||
|
|
||||||
|
## Problem (2026-07-01, 劳务派遣协议案)
|
||||||
|
|
||||||
|
When lxml serializes XML (via `etree.tostring()` or python-docx's `Document.save()`), it outputs:
|
||||||
|
- **Single-quote** XML declaration: `<?xml version='1.0' encoding='UTF-8' standalone='yes'?>`
|
||||||
|
- **LF** line endings (`\n`)
|
||||||
|
|
||||||
|
Original docx files (created by Word/WPS/OnlyOffice) use:
|
||||||
|
- **Double-quote** XML declaration: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`
|
||||||
|
- **CRLF** line endings (`\r\n`)
|
||||||
|
|
||||||
|
**OnlyOffice cannot open docx files with single-quote XML declarations.** The file appears structurally valid (ZIP ok, XML parses fine, python-docx loads it, even x2t can convert it to PDF), but the OnlyOffice web editor refuses to open it.
|
||||||
|
|
||||||
|
## Affected Files
|
||||||
|
|
||||||
|
Only XML files that were **re-serialized by lxml** are affected. In a typical ContractEditor workflow:
|
||||||
|
- `word/document.xml` — always re-serialized (main editing target)
|
||||||
|
- `word/settings.xml` — re-serialized if trackRevisions was added/modified
|
||||||
|
|
||||||
|
Other XML files (styles.xml, fontTable.xml, theme1.xml, etc.) that were read and written back unchanged via `zipfile` retain their original format.
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
```python
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
def check_docx_xml_format(docx_path):
|
||||||
|
"""Check if any XML files have problematic single-quote declarations."""
|
||||||
|
issues = []
|
||||||
|
with zipfile.ZipFile(docx_path) as z:
|
||||||
|
for name in z.namelist():
|
||||||
|
if name.endswith('.xml') or name.endswith('.rels'):
|
||||||
|
data = z.read(name).decode('utf-8')
|
||||||
|
first_line = data.split('\n')[0]
|
||||||
|
has_single_quotes = "version='1.0'" in first_line
|
||||||
|
has_lf_only = '\r\n' not in data[:200]
|
||||||
|
if has_single_quotes or has_lf_only:
|
||||||
|
issues.append((name, has_single_quotes, has_lf_only))
|
||||||
|
return issues
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fix Script
|
||||||
|
|
||||||
|
```python
|
||||||
|
import zipfile
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
def fix_xml_declarations(docx_path, output_path=None):
|
||||||
|
"""
|
||||||
|
Fix lxml-serialized XML files inside a docx:
|
||||||
|
1. Single quotes -> double quotes in XML declaration
|
||||||
|
2. LF -> CRLF line endings (only if file has no CRLF)
|
||||||
|
|
||||||
|
If output_path is None, fixes in-place (via temp file + rename).
|
||||||
|
"""
|
||||||
|
if output_path is None:
|
||||||
|
output_path = docx_path
|
||||||
|
|
||||||
|
tmp_fd, tmp_path = tempfile.mkstemp(suffix='.docx')
|
||||||
|
os.close(tmp_fd)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(docx_path, 'r') as zin:
|
||||||
|
with zipfile.ZipFile(tmp_path, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for item in zin.infolist():
|
||||||
|
data = zin.read(item.filename)
|
||||||
|
|
||||||
|
if item.filename.endswith('.xml') or item.filename.endswith('.rels'):
|
||||||
|
text = data.decode('utf-8')
|
||||||
|
|
||||||
|
# Fix 1: Single quotes -> double quotes in XML declaration
|
||||||
|
text = re.sub(
|
||||||
|
r"<\?xml version='1\.0' encoding='UTF-8' standalone='yes'\?>",
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
|
||||||
|
text
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fix 2: LF -> CRLF (only if no CRLF present)
|
||||||
|
if '\r\n' not in text and '\n' in text:
|
||||||
|
text = text.replace('\n', '\r\n')
|
||||||
|
|
||||||
|
data = text.encode('utf-8')
|
||||||
|
|
||||||
|
zout.writestr(item, data)
|
||||||
|
|
||||||
|
os.replace(tmp_path, output_path)
|
||||||
|
except:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Usage after ContractEditor.save() or manual zipfile write:
|
||||||
|
# fix_xml_declarations('/tmp/【修】contract.docx')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### After ContractEditor.save()
|
||||||
|
```python
|
||||||
|
ed = ContractEditor(src)
|
||||||
|
# ... edits ...
|
||||||
|
ed.save(output_path)
|
||||||
|
fix_xml_declarations(output_path) # Must run after every save
|
||||||
|
```
|
||||||
|
|
||||||
|
### After manual zipfile+lxml write
|
||||||
|
```python
|
||||||
|
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for item in zin.infolist():
|
||||||
|
# ... write files ...
|
||||||
|
pass
|
||||||
|
|
||||||
|
fix_xml_declarations(output_path) # Must run after ZIP is closed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Insight
|
||||||
|
|
||||||
|
- `x2t` (OnlyOffice converter CLI) tolerates single-quote declarations — it can convert the "broken" file to PDF successfully
|
||||||
|
- The **OnlyOffice web editor** (WOPI-based document editing) does NOT tolerate single-quote declarations
|
||||||
|
- `python-docx Document()` opens the file fine (lxml parses both formats)
|
||||||
|
- Standard validation tools (zipfile.testzip(), etree.fromstring()) all pass
|
||||||
|
|
||||||
|
This makes the issue hard to diagnose — everything looks valid except OnlyOffice refuses to open it. The **only reliable test** is checking the raw bytes of the XML declaration in the ZIP.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# A类手动编号顺延 — 段落级 DEL/INS 模式
|
||||||
|
|
||||||
|
实战来源:CT维保合同-香花桥(2026-06-26)。新增"9. 第三方侵权"条款后,需将原9→10、10→11、11→12、12→13、13→14 顺延。所有条款编号均为手动文本(A类,run内w:t文字,无numPr)。
|
||||||
|
|
||||||
|
## 核心模式
|
||||||
|
|
||||||
|
对每个需顺延的段落,找到包含旧编号的 run,用**段落级** DEL/INS 替换:
|
||||||
|
|
||||||
|
```python
|
||||||
|
for old_num, new_num in renumber_map.items():
|
||||||
|
for r in p.findall(f'{{{W}}}r'):
|
||||||
|
t = r.find(f'{{{W}}}t')
|
||||||
|
if t is None or t.text is None: continue
|
||||||
|
if t.text.strip().startswith(str(old_num)):
|
||||||
|
# 1. DEL run: 旧编号
|
||||||
|
del_run = deepcopy(r)
|
||||||
|
del_run.set(f'{{{W}}}rsidDel', rsid)
|
||||||
|
del_t = del_run.find(f'{{{W}}}t')
|
||||||
|
del_t.tag = f'{{{W}}}delText'
|
||||||
|
del_t.text = str(old_num)
|
||||||
|
|
||||||
|
del_w = etree.Element(f'{{{W}}}del')
|
||||||
|
del_w.set(f'{{{W}}}id', str(nid)); nid += 1
|
||||||
|
del_w.set(f'{{{W}}}author', 'WB')
|
||||||
|
del_w.set(f'{{{W}}}date', rev_date)
|
||||||
|
del_w.append(del_run)
|
||||||
|
|
||||||
|
# 2. INS run: 新编号
|
||||||
|
ins_run = deepcopy(r)
|
||||||
|
ins_run.set(f'{{{W}}}rsidR', rsid)
|
||||||
|
ins_t = ins_run.find(f'{{{W}}}t')
|
||||||
|
ins_t.text = str(new_num)
|
||||||
|
|
||||||
|
ins_w = etree.Element(f'{{{W}}}ins')
|
||||||
|
ins_w.set(f'{{{W}}}id', str(nid)); nid += 1
|
||||||
|
ins_w.set(f'{{{W}}}author', 'WB')
|
||||||
|
ins_w.set(f'{{{W}}}date', rev_date)
|
||||||
|
ins_w.append(ins_run)
|
||||||
|
|
||||||
|
# 3. 原 run 去掉编号前缀
|
||||||
|
t.text = t.text[len(str(old_num)):]
|
||||||
|
|
||||||
|
# 4. DEL + INS 插入在原 run 之前
|
||||||
|
r.addprevious(ins_w)
|
||||||
|
r.addprevious(del_w)
|
||||||
|
break
|
||||||
|
break # 每个段落只改一个编号
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键点
|
||||||
|
|
||||||
|
1. **DEL/INS 在段落级**(`w:p` 的直接子元素),不是 run 内
|
||||||
|
2. **从后往前处理**:如果用索引遍历,从后往前避免 offset 漂移
|
||||||
|
3. **只匹配run开头**:`t.text.strip().startswith(str(old_num))` 确保只匹配编号前缀
|
||||||
|
4. **原 run 保留剩余文本**:`t.text = t.text[len(str(old_num)):]` 去掉编号后保留标题文字
|
||||||
|
5. **ID 递增**:每个 DEL/INS 用独立 id,从 `max_id + 1` 起
|
||||||
|
|
||||||
|
## 与 add_clause 的区别
|
||||||
|
|
||||||
|
- `add_clause` / `add_clause_before`:创建**全新段落**(整段 w:ins)
|
||||||
|
- 本模式:修改**已有段落**的第一个 run 的编号,其余内容不动
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
|
||||||
|
- 新增条款后,后续**手动编号**(A类)的条款需要顺延
|
||||||
|
- 不适用于自动编号(B类)——自动编号由 number.xml 引擎处理,修改 run 内文字无效
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# 手动合同审查:具体修改规则(非角色约束)
|
||||||
|
|
||||||
|
> Doro 2026-07-02 明确:"我需要你遵守的是具体修改规则,不是角色。"
|
||||||
|
> 这些规则不因"手动操作"还是"workflow执行"而有任何区别。
|
||||||
|
|
||||||
|
## 十条硬规则
|
||||||
|
|
||||||
|
1. **修订精准到字,不整段 del+ins**
|
||||||
|
- 改一个字只标记一个字的 del+ins
|
||||||
|
- 不允许为了方便把整句/整段删掉重写
|
||||||
|
|
||||||
|
2. **INS run 字体/字号与原文同段落一致**
|
||||||
|
- 每个 INS run 的 rPr(sz/bold/rFonts)必须与同段落其他非INS run一致
|
||||||
|
- 签署页特别注意:"甲方:""乙方:"标签和名称可能原文字号不同,INS必须匹配标签字号
|
||||||
|
|
||||||
|
3. **格式、大小与原文保持一致**
|
||||||
|
- 段落缩进(firstLine)、行距(spacing)、段落样式(pStyle)全部与原文同级段落一致
|
||||||
|
- 新增条款标题必须继承原文条款标题的样式
|
||||||
|
|
||||||
|
4. **编号顺延要通读全文确认**
|
||||||
|
- 插入新条款后,后续条款编号必须顺延
|
||||||
|
- 必须通读全文确认编号链连续无跳号
|
||||||
|
|
||||||
|
5. **不擅自填写合同空白内容**
|
||||||
|
- 空白的商业条款(金额、期限、数量、质量标准等)不动
|
||||||
|
- 空白 = 留给签约双方自行填写,不是让审查人补充
|
||||||
|
|
||||||
|
6. **不做独立法律判断**
|
||||||
|
- 不在审查中做"这个条款合不合法"的独立判断
|
||||||
|
- 只按reviewer的issue清单执行修改
|
||||||
|
|
||||||
|
7. **不站自己的立场改客户的商业安排**
|
||||||
|
- 客户已经做出的商业决策不否定
|
||||||
|
- 例:客户选择"反委托代发工资",不能改成"乙方直接发"
|
||||||
|
- 只能在客户选择的框架内加保护条款
|
||||||
|
|
||||||
|
8. **批注只写修改方案,不写理由**
|
||||||
|
- ❌ "建议修改为……,因为……"
|
||||||
|
- ✅ "建议修改为……"
|
||||||
|
- 不加【新增】【修改】等标签前缀
|
||||||
|
|
||||||
|
9. **金额是商业条款不动**
|
||||||
|
- 无论金额看起来是否"合理",绝对不改
|
||||||
|
- 金额矛盾也只批注提示,不做修改
|
||||||
|
|
||||||
|
10. **原文批注/修订不动**
|
||||||
|
- 其他人(华诚-Z、法务、屠佳青等)的修订和批注保留原样
|
||||||
|
- 不删除、不修改、不合并他人的批注
|
||||||
|
- 除非Doro明确指示合并(如"华诚-Z的修订人改为WB")
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
**遵守的是规则本身,不是"我现在扮演什么角色"。** 不管是workflow的editor角色执行、还是Doro直接让我手动改合同,这十条规则完全一样,不打折扣。
|
||||||
|
|
||||||
|
## 反面教材(2026-07-01)
|
||||||
|
|
||||||
|
- 反委托代发工资协议:站自己立场否定客户的反委托安排(版本1直接取消反委托)→ 违反第7条
|
||||||
|
- 填写空白的"质量保证期___个月" → 违反第5条
|
||||||
|
- 批注写理由 → 违反第8条
|
||||||
|
- 劳务派遣协议整段del+ins → 违反第1条
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Merge Layered Revisions with Priority (Accept Inner Author's Edits)
|
||||||
|
|
||||||
|
## Scenario (2026-07-03 模特合作协议案)
|
||||||
|
|
||||||
|
File has two layers of tracked changes:
|
||||||
|
- **Layer 1 (WB)**: Original review modifications
|
||||||
|
- **Layer 2 (华诚-Z)**: User edited on top of WB's tracked changes
|
||||||
|
|
||||||
|
Result: 华诚-Z's `w:del` elements are **nested inside** WB's `w:ins` elements — meaning 华诚-Z deleted portions of what WB had inserted.
|
||||||
|
|
||||||
|
User instruction: "以华诚-Z为准" (prioritize 华诚-Z), then unify all author names to WB.
|
||||||
|
|
||||||
|
## Three-Step Algorithm
|
||||||
|
|
||||||
|
### Step 1: Accept nested deletions (inner author wins)
|
||||||
|
|
||||||
|
Find all `w:del[author=华诚-Z]` nested inside `w:ins[author=WB]` and remove them (= accept the deletion):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def accept_nested_deletions(body, inner_author='华诚-Z', outer_author='WB'):
|
||||||
|
for ins_elem in body.findall(f'.//{W}ins'):
|
||||||
|
if ins_elem.get(f'{W}author') != outer_author:
|
||||||
|
continue
|
||||||
|
for del_elem in ins_elem.findall(f'.//{W}del'):
|
||||||
|
if del_elem.get(f'{W}author') == inner_author:
|
||||||
|
parent = del_elem.getparent()
|
||||||
|
parent.remove(del_elem)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Remove empty outer elements
|
||||||
|
|
||||||
|
After accepting nested deletions, some WB ins elements may be empty (all their content was deleted by 华诚-Z):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def remove_empty_ins(body):
|
||||||
|
for ins_elem in body.findall(f'.//{W}ins'):
|
||||||
|
has_text = False
|
||||||
|
for t in ins_elem.findall(f'.//{W}t'):
|
||||||
|
if t.text and t.text.strip():
|
||||||
|
has_text = True
|
||||||
|
break
|
||||||
|
if not has_text:
|
||||||
|
parent = ins_elem.getparent()
|
||||||
|
if parent is not None:
|
||||||
|
parent.remove(ins_elem)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Unify author names
|
||||||
|
|
||||||
|
```python
|
||||||
|
def rename_author(body, old_author, new_author):
|
||||||
|
count = 0
|
||||||
|
for elem in body.iter():
|
||||||
|
author = elem.get(f'{W}author')
|
||||||
|
if author == old_author:
|
||||||
|
elem.set(f'{W}author', new_author)
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Flow
|
||||||
|
|
||||||
|
```python
|
||||||
|
from docx import Document
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
doc = Document('input.docx')
|
||||||
|
body = doc.element.body
|
||||||
|
|
||||||
|
# Step 1: Accept 华诚-Z deletions of WB content
|
||||||
|
accept_nested_deletions(body, inner_author='华诚-Z', outer_author='WB')
|
||||||
|
|
||||||
|
# Step 2: Clean up empty WB ins elements
|
||||||
|
remove_empty_ins(body)
|
||||||
|
|
||||||
|
# Step 3: Rename 华诚-Z → WB
|
||||||
|
rename_author(body, '华诚-Z', 'WB')
|
||||||
|
|
||||||
|
doc.save('output.docx')
|
||||||
|
```
|
||||||
|
|
||||||
|
## After Merge: Additional Modifications
|
||||||
|
|
||||||
|
After merging, you can continue adding new WB tracked changes on the unified file (e.g., reverting specific clauses to template wording). Use standard tracked change creation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def make_del(text, rPr=None, author='WB', date='2026-07-03T06:00:00Z'):
|
||||||
|
d = etree.Element(f'{W}del')
|
||||||
|
d.set(f'{W}id', str(abs(hash(text)) % 100000))
|
||||||
|
d.set(f'{W}author', author)
|
||||||
|
d.set(f'{W}date', date)
|
||||||
|
r = etree.SubElement(d, f'{W}r')
|
||||||
|
if rPr is not None:
|
||||||
|
r.append(deepcopy(rPr))
|
||||||
|
dt = etree.SubElement(r, f'{W}delText')
|
||||||
|
dt.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||||
|
dt.text = text
|
||||||
|
return d
|
||||||
|
|
||||||
|
def make_ins(text, rPr=None, author='WB', date='2026-07-03T06:00:00Z'):
|
||||||
|
ins = etree.Element(f'{W}ins')
|
||||||
|
ins.set(f'{W}id', str(abs(hash(text + 'ins')) % 100000))
|
||||||
|
ins.set(f'{W}author', author)
|
||||||
|
ins.set(f'{W}date', date)
|
||||||
|
r = etree.SubElement(ins, f'{W}r')
|
||||||
|
if rPr is not None:
|
||||||
|
r.append(deepcopy(rPr))
|
||||||
|
t = etree.SubElement(r, f'{W}t')
|
||||||
|
t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||||
|
t.text = text
|
||||||
|
return ins
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After merge:
|
||||||
|
- `set(elem.get(W+'author') for elem in body.iter() if elem.get(W+'author'))` should return `{'WB'}` only
|
||||||
|
- Count ins/del elements to confirm reasonable numbers
|
||||||
|
- Verify key clauses read correctly in "accepted" view
|
||||||
|
|
||||||
|
## Key Distinction from `unify-author-wb.py`
|
||||||
|
|
||||||
|
The `scripts/unify-author-wb.py` script **only renames authors** — it does NOT handle nested deletions. If 华诚-Z has `w:del` inside WB's `w:ins`, just running unify will rename the del to WB but **leave the deleted content still marked as deleted inside the insertion** — creating a confusing state where WB appears to both insert and delete the same text.
|
||||||
|
|
||||||
|
**Always run the three-step algorithm** when inner author has modified outer author's tracked changes.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Mixed Inherited/Explicit Font Size Fix (Document-Wide)
|
||||||
|
|
||||||
|
## Problem (2026-07-01 生育友好宣传阵地建设协议)
|
||||||
|
|
||||||
|
Source document has **mixed font sizing** in body text:
|
||||||
|
- Some runs have explicit `sz=24` (12pt) — e.g., section headings, specific clauses
|
||||||
|
- Other runs have **no explicit sz** — inherit from Normal style (`sz=21` / 10.5pt)
|
||||||
|
- WB INS runs mostly got `sz=24` correctly, but the mix of explicit + inherited in **original** runs creates visual inconsistency
|
||||||
|
|
||||||
|
Doro complaint: "文字大小不一致,修改" — the rendered result shows mixed sizes.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
- `docDefaults` / Normal style = 10.5pt (sz=21)
|
||||||
|
- Many body runs (P12+) have explicit sz=24 (from original author or conversion)
|
||||||
|
- ~72 original runs have NO explicit sz → inherit 10.5pt → render smaller
|
||||||
|
- OnlyOffice renders the mix faithfully → visible inconsistency
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
```python
|
||||||
|
from docx import Document
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
doc = Document('file.docx')
|
||||||
|
print(f'Normal style sz: {doc.styles["Normal"].font.size}') # If 133350 EMU = 10.5pt
|
||||||
|
|
||||||
|
sizes = Counter()
|
||||||
|
for p in doc.paragraphs[BODY_START:BODY_END]:
|
||||||
|
for run in p.runs:
|
||||||
|
if run.text.strip():
|
||||||
|
sizes[run.font.size.pt if run.font.size else 'inherited'] += 1
|
||||||
|
|
||||||
|
# If both 'inherited' and explicit size (e.g. 12.0) appear → mixed problem
|
||||||
|
print(sizes.most_common())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fix Pattern (Full Body Range)
|
||||||
|
|
||||||
|
Unlike the INS-only sweep, this fix targets ALL runs in the body text range:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import zipfile, re
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||||
|
|
||||||
|
# 1. Identify body range (skip title/preamble and signature)
|
||||||
|
BODY_START = 12 # First body content paragraph index
|
||||||
|
BODY_END = 56 # Last body paragraph (exclusive)
|
||||||
|
TARGET_SZ = '24' # From explicit runs in body (majority value)
|
||||||
|
|
||||||
|
# 2. Fix ALL runs in body range
|
||||||
|
for pidx in range(BODY_START, min(BODY_END, len(paras))):
|
||||||
|
p = paras[pidx]
|
||||||
|
|
||||||
|
# Plain runs
|
||||||
|
for r in p.findall(f'{WNS}r'):
|
||||||
|
t_elem = r.find(f'{WNS}t')
|
||||||
|
if t_elem is None or not (t_elem.text or '').strip():
|
||||||
|
continue
|
||||||
|
rpr = r.find(f'{WNS}rPr')
|
||||||
|
if rpr is None:
|
||||||
|
rpr = etree.SubElement(r, f'{WNS}rPr')
|
||||||
|
r.insert(0, rpr)
|
||||||
|
sz = rpr.find(f'{WNS}sz')
|
||||||
|
if sz is None:
|
||||||
|
sz = etree.SubElement(rpr, f'{WNS}sz')
|
||||||
|
sz.set(f'{WNS}val', TARGET_SZ)
|
||||||
|
szCs = rpr.find(f'{WNS}szCs')
|
||||||
|
if szCs is None:
|
||||||
|
szCs = etree.SubElement(rpr, f'{WNS}szCs')
|
||||||
|
szCs.set(f'{WNS}val', TARGET_SZ)
|
||||||
|
|
||||||
|
# INS runs
|
||||||
|
for ins in p.findall(f'{WNS}ins'):
|
||||||
|
for r in ins.findall(f'{WNS}r'):
|
||||||
|
# same logic as above
|
||||||
|
...
|
||||||
|
|
||||||
|
# DEL runs (for visual consistency in markup view)
|
||||||
|
for d in p.findall(f'{WNS}del'):
|
||||||
|
for r in d.findall(f'{WNS}r'):
|
||||||
|
# same logic
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Distinctions from INS-Only Fix
|
||||||
|
|
||||||
|
| Aspect | INS-only sweep | Full body range fix |
|
||||||
|
|--------|---------------|---------------------|
|
||||||
|
| Scope | Only WB INS runs | ALL runs (plain + INS + DEL) |
|
||||||
|
| Trigger | INS runs missing sz | Doro reports "文字大小不一致" |
|
||||||
|
| Root cause | add_clause/tracked_replace gaps | Source document mixed inheritance |
|
||||||
|
| Target sz | From neighboring runs | From majority explicit sz in body |
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- Doro says "文字大小不一致" on a delivered file
|
||||||
|
- `wb-ins-font-verify.py` passes (INS runs OK) but rendered output still shows mixed sizes
|
||||||
|
- Diagnostic shows body runs split between `inherited` and explicit sz
|
||||||
|
|
||||||
|
## Important: Don't Change Preamble/Signature
|
||||||
|
|
||||||
|
- Title/header (e.g., P0-P2): larger sz by design (22pt/sz=44) — don't touch
|
||||||
|
- Party info (P3-P10): may use different sz — don't touch unless in body range
|
||||||
|
- Signature area (P56+): often sz=21 (10.5pt) — don't touch
|
||||||
|
- Only fix the **body text range** where sz should be uniform
|
||||||
|
|
||||||
|
## Relationship to 格式保留铁律
|
||||||
|
|
||||||
|
This fix does NOT violate "格式保留铁律" (don't change original formatting) because:
|
||||||
|
- The original document's **intent** is uniform 12pt body text (evidenced by majority explicit sz=24)
|
||||||
|
- The missing sz is a **formatting omission** (author forgot to set explicit sz on some runs)
|
||||||
|
- The fix makes the document render as the original author intended
|
||||||
|
- This is different from "changing 仿宋_GB2312 to 仿宋" (that changes the actual format choice)
|
||||||
|
|
||||||
|
BUT: if the original document intentionally uses different sizes in body (e.g., smaller text for notes, larger for headings), don't blindly unify. Check the pattern first.
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# 修改已有tracked changes的作者和文本内容
|
||||||
|
|
||||||
|
## 场景
|
||||||
|
- 合并用户在OnlyOffice中的修订(author如"华诚-Z"改为"WB")
|
||||||
|
- 修改INS元素中的文本内容(如更新法律措辞)
|
||||||
|
- 修改批注作者(comments.xml中的w:comment author属性)
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
### 修改tracked change作者
|
||||||
|
```python
|
||||||
|
from lxml import etree
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||||
|
|
||||||
|
# 打开docx,修改document.xml
|
||||||
|
z_in = zipfile.ZipFile('input.docx', 'r')
|
||||||
|
z_out = zipfile.ZipFile('output.docx', 'w')
|
||||||
|
|
||||||
|
# 复制非document.xml的文件
|
||||||
|
for item in z_in.namelist():
|
||||||
|
if item != 'word/document.xml':
|
||||||
|
z_out.writestr(item, z_in.read(item))
|
||||||
|
|
||||||
|
# 修改tracked change作者
|
||||||
|
with z_in.open('word/document.xml') as f:
|
||||||
|
tree = etree.parse(f)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
for elem in root.iter():
|
||||||
|
tag = etree.QName(elem.tag).localname
|
||||||
|
if tag in ('ins', 'del'):
|
||||||
|
old_author = elem.get(f'{WNS}author', '')
|
||||||
|
if old_author == '旧作者名':
|
||||||
|
elem.set(f'{WNS}author', 'WB')
|
||||||
|
|
||||||
|
z_out.writestr('word/document.xml', etree.tostring(tree, encoding='UTF-8', xml_declaration=True, standalone=True))
|
||||||
|
z_in.close()
|
||||||
|
z_out.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改INS文本内容
|
||||||
|
```python
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# 定位特定段落中的INS元素
|
||||||
|
body = root.find(f'{WNS}body')
|
||||||
|
paras = body.findall(f'{WNS}p')
|
||||||
|
target_para = paras[12] # 按索引定位
|
||||||
|
|
||||||
|
# 删除旧的INS元素(按作者筛选)
|
||||||
|
for ins in list(target_para.findall(f'{WNS}ins')):
|
||||||
|
author = ins.get(f'{WNS}author', '')
|
||||||
|
if author == '目标作者':
|
||||||
|
target_para.remove(ins)
|
||||||
|
|
||||||
|
# 添加新的INS元素
|
||||||
|
new_ins = etree.SubElement(target_para, f'{WNS}ins')
|
||||||
|
new_ins.set(f'{WNS}author', 'WB')
|
||||||
|
new_ins.set(f'{WNS}date', now)
|
||||||
|
new_r = etree.SubElement(new_ins, f'{WNS}r')
|
||||||
|
|
||||||
|
# 从同段落的原文run复制格式
|
||||||
|
orig_runs = target_para.findall(f'{WNS}r')
|
||||||
|
if orig_runs:
|
||||||
|
orig_rpr = orig_runs[0].find(f'{WNS}rPr')
|
||||||
|
if orig_rpr is not None:
|
||||||
|
new_r.append(deepcopy(orig_rpr))
|
||||||
|
|
||||||
|
new_t = etree.SubElement(new_r, f'{WNS}t')
|
||||||
|
new_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||||
|
new_t.text = '新的插入文本'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改批注作者
|
||||||
|
```python
|
||||||
|
# 修改comments.xml中的作者
|
||||||
|
if 'word/comments.xml' in z_in.namelist():
|
||||||
|
with z_in.open('word/comments.xml') as f:
|
||||||
|
ctree = etree.parse(f)
|
||||||
|
croot = ctree.getroot()
|
||||||
|
for c in croot.findall(f'{WNS}comment'):
|
||||||
|
if c.get(f'{WNS}author', '') == '旧作者名':
|
||||||
|
c.set(f'{WNS}author', 'WB')
|
||||||
|
z_out.writestr('word/comments.xml', etree.tostring(ctree, encoding='UTF-8', xml_declaration=True, standalone=True))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 在已有WB INS元素内修改部分文本(2026-07-01 反委托代发工资协议)
|
||||||
|
|
||||||
|
当段落文本全部是WB INS(无普通w:r),需要替换其中某一句时,**不能删除整个INS重建**(会丢失该INS中其他文本的修订标记)。正确手法:**trim原INS的w:t + addnext插入DEL/INS**。
|
||||||
|
|
||||||
|
```python
|
||||||
|
old_sentence = "退回派遣员工由乙方依法自行安置处理,与甲方无涉。"
|
||||||
|
new_sentence = "派遣员工退回后由乙方依法负责安置处理。因乙方安置不当导致甲方被追究责任的,乙方应赔偿甲方因此遭受的全部损失。"
|
||||||
|
|
||||||
|
for child in list(p):
|
||||||
|
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||||
|
if tag == 'ins' and child.get(f'{WNS}author') == 'WB':
|
||||||
|
for r in child.findall(f'{WNS}r'):
|
||||||
|
for t in r.findall(f'{WNS}t'):
|
||||||
|
if t.text and old_sentence in t.text:
|
||||||
|
rpr_copy = copy.deepcopy(r.find(f'{WNS}rPr')) if r.find(f'{WNS}rPr') is not None else None
|
||||||
|
|
||||||
|
# 1. Trim原INS文本(去掉被替换的句子)
|
||||||
|
t.text = t.text.replace(old_sentence, "")
|
||||||
|
|
||||||
|
# 2. 创建DEL
|
||||||
|
del_elem = etree.Element(f'{WNS}del')
|
||||||
|
del_elem.set(f'{WNS}id', next_id())
|
||||||
|
del_elem.set(f'{WNS}author', 'WB')
|
||||||
|
del_elem.set(f'{WNS}date', rev_date)
|
||||||
|
del_r = etree.SubElement(del_elem, f'{WNS}r')
|
||||||
|
if rpr_copy: del_r.insert(0, copy.deepcopy(rpr_copy))
|
||||||
|
del_r.set(f'{WNS}rsidDel', rsid)
|
||||||
|
del_t = etree.SubElement(del_r, f'{WNS}delText')
|
||||||
|
del_t.set(XML_SPACE, 'preserve')
|
||||||
|
del_t.text = old_sentence
|
||||||
|
|
||||||
|
# 3. 创建INS
|
||||||
|
ins_elem = etree.Element(f'{WNS}ins')
|
||||||
|
ins_elem.set(f'{WNS}id', next_id())
|
||||||
|
ins_elem.set(f'{WNS}author', 'WB')
|
||||||
|
ins_elem.set(f'{WNS}date', rev_date)
|
||||||
|
ins_r = etree.SubElement(ins_elem, f'{WNS}r')
|
||||||
|
if rpr_copy: ins_r.insert(0, copy.deepcopy(rpr_copy))
|
||||||
|
ins_r.set(f'{WNS}rsidR', rsid)
|
||||||
|
ins_t = etree.SubElement(ins_r, f'{WNS}t')
|
||||||
|
ins_t.set(XML_SPACE, 'preserve')
|
||||||
|
ins_t.text = new_sentence
|
||||||
|
|
||||||
|
# 4. 插入到原INS之后(addnext保证顺序)
|
||||||
|
child.addnext(ins_elem) # 后插的在后面
|
||||||
|
child.addnext(del_elem) # 后插的在前面 → 最终: [原INS] [DEL] [INS]
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键点**:
|
||||||
|
- `addnext` 两次:先插INS再插DEL,后插的排前面,最终顺序:`[原INS(trimmed)] [DEL旧句] [INS新句]`
|
||||||
|
- 绝不能 `p.remove(child)` 再重建——会丢失INS中其他未改动的文本
|
||||||
|
- rPr必须从原INS的run深拷贝,不要从全文body_rpr取(字号可能不同)
|
||||||
|
|
||||||
|
### 给全INS段落补充条款编号(2026-07-01)
|
||||||
|
|
||||||
|
段落所有文本都是WB INS时,编号INS插到pPr之后:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ins_num = etree.Element(f'{WNS}ins')
|
||||||
|
ins_num.set(f'{WNS}id', next_id()); ins_num.set(f'{WNS}author', 'WB'); ins_num.set(f'{WNS}date', rev_date)
|
||||||
|
ins_r = etree.SubElement(ins_num, f'{WNS}r')
|
||||||
|
ins_r.insert(0, copy.deepcopy(existing_rpr)) # 从同段落INS run深拷贝
|
||||||
|
ins_r.set(f'{WNS}rsidR', rsid)
|
||||||
|
ins_t = etree.SubElement(ins_r, f'{WNS}t')
|
||||||
|
ins_t.set(XML_SPACE, 'preserve'); ins_t.text = "第X条 "
|
||||||
|
|
||||||
|
ppr = p.find(f'{WNS}pPr')
|
||||||
|
if ppr is not None: ppr.addnext(ins_num)
|
||||||
|
else: p.insert(0, ins_num)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新INS元素eastAsia字体显式补齐
|
||||||
|
|
||||||
|
原文WB INS run可能**没有显式eastAsia属性**(靠docDefaults回退),但新INS run**必须显式设置eastAsia=宋体**,否则修订上下文中可能丢失回退。post-save sweep:
|
||||||
|
|
||||||
|
```python
|
||||||
|
for p in body.findall(f'{WNS}p'):
|
||||||
|
for ins in p.findall(f'{WNS}ins'):
|
||||||
|
for r in ins.findall(f'{WNS}r'):
|
||||||
|
text = ''.join(t.text for t in r.findall(f'{WNS}t') if t.text)
|
||||||
|
if not any('\u4e00' <= c <= '\u9fff' for c in text): continue
|
||||||
|
rpr = r.find(f'{WNS}rPr')
|
||||||
|
if rpr is None:
|
||||||
|
rpr = etree.Element(f'{WNS}rPr'); r.insert(0, rpr)
|
||||||
|
rf = rpr.find(f'{WNS}rFonts')
|
||||||
|
if rf is None: rf = etree.SubElement(rpr, f'{WNS}rFonts')
|
||||||
|
if not rf.get(f'{WNS}eastAsia'): rf.set(f'{WNS}eastAsia', '宋体')
|
||||||
|
if not rf.get(f'{WNS}ascii'): rf.set(f'{WNS}ascii', '宋体')
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证方法
|
||||||
|
```python
|
||||||
|
# 验证所有作者已更改
|
||||||
|
content = z_out.read('word/document.xml').decode('utf-8', 'ignore')
|
||||||
|
authors = set(re.findall(r'w:author="([^"]+)"', content))
|
||||||
|
assert '旧作者名' not in authors, f"仍有旧作者: {authors}"
|
||||||
|
|
||||||
|
# 验证批注作者
|
||||||
|
if 'word/comments.xml' in z_out.namelist():
|
||||||
|
with z_out.open('word/comments.xml') as f:
|
||||||
|
ctree = etree.parse(f)
|
||||||
|
for c in ctree.getroot().findall(f'{WNS}comment'):
|
||||||
|
assert c.get(f'{WNS}author') != '旧作者名'
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ 铁律
|
||||||
|
1. **修改前必须备份原文件**:覆盖含第三方修订的文件 = 不可逆丢失
|
||||||
|
2. **只改作者名,不改文本**:除非明确要求修改INS内容
|
||||||
|
3. **zipfile不能原地读写**:必须先读后写临时文件,再用os.replace
|
||||||
|
4. **保留comments.xml中的批注锚点**:只改author属性,不改id/content/anchor
|
||||||
|
|
||||||
|
## 实证(2026-07-01 反委托代发工资协议)
|
||||||
|
华诚-Z在OnlyOffice中做了3处修订(第六条去法条引用、第七条简化纠正流程、第八条加退回员工安置)。后续制作版本时覆盖了所有中间文件,导致华诚-Z修订痕迹丢失。最终通过系统化文件扫描在/tmp/v1_doro_updated.docx中找到仍含华诚-Z作者的文件,提取修订内容后在最终版本中恢复。
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Multi-Version Contract Comparison Table (三版对比表)
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
When Maggie/Doro asks to compare multiple versions of a contract (typically: template / counterparty revision / our revision), produce a structured docx comparison table.
|
||||||
|
|
||||||
|
## Pattern (2026-07-03 模特合作协议 session)
|
||||||
|
|
||||||
|
### Document Setup
|
||||||
|
- **Landscape orientation** for 4-5 columns: `section.orientation = 1; page_width=Cm(29.7); page_height=Cm(21.0)`
|
||||||
|
- Narrow margins: 1.2-1.5cm all sides
|
||||||
|
- Font size 8.5-9pt for table cells (fits more content)
|
||||||
|
|
||||||
|
### Table Structure
|
||||||
|
| 条款 | 【模版】 | 版本A(对方修订) | 版本B(我方修订) | 双方协商一致 |
|
||||||
|
|------|---------|------------------|-----------------|-------------|
|
||||||
|
|
||||||
|
### Red Font for Differences
|
||||||
|
- Column N is red when its content differs from other versions
|
||||||
|
- Use `RGBColor(0xFF, 0x00, 0x00)` on the run
|
||||||
|
- "协商一致" column: red = current text doesn't match consensus → needs modification
|
||||||
|
|
||||||
|
### Yellow Background for Consensus Column
|
||||||
|
```python
|
||||||
|
def set_cell_shading(cell, color):
|
||||||
|
tc = cell._element
|
||||||
|
tcPr = tc.find(qn('w:tcPr'))
|
||||||
|
if tcPr is None:
|
||||||
|
tcPr = OxmlElement('w:tcPr')
|
||||||
|
tc.insert(0, tcPr)
|
||||||
|
shading = OxmlElement('w:shd')
|
||||||
|
shading.set(qn('w:fill'), color) # e.g. 'FFF8E1' for light yellow
|
||||||
|
shading.set(qn('w:val'), 'clear')
|
||||||
|
tcPr.append(shading)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Header Row Styling
|
||||||
|
- Blue background (`D9E2F3`)
|
||||||
|
- Bold, centered, font size 8.5pt
|
||||||
|
|
||||||
|
### Data Structure in Code
|
||||||
|
```python
|
||||||
|
# Each row: (clause_name, col1_text, col2_text, col3_text, col4_text, col2_red, col3_red, col4_red)
|
||||||
|
rows = [
|
||||||
|
('条款名',
|
||||||
|
'模版内容',
|
||||||
|
'对方修订内容',
|
||||||
|
'我方修订内容',
|
||||||
|
'协商一致内容',
|
||||||
|
True, # col2 red? (differs from others)
|
||||||
|
False, # col3 red?
|
||||||
|
True), # col4 red? (doesn't match consensus)
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Legend at Bottom
|
||||||
|
Include a legend explaining what red means in each column:
|
||||||
|
- 版本A列红色 = 与模版/我方版不一致(对方的修改)
|
||||||
|
- 版本B列红色 = 与模版不一致(我方的修改)
|
||||||
|
- 协商一致列红色 = 当前文本与协商一致不符,需要修改
|
||||||
|
|
||||||
|
## Key Lessons
|
||||||
|
1. **Read all three files from Nextcloud** using `sudo find ~/nextcloud/data/data/...` path
|
||||||
|
2. **Extract paragraph text** using python-docx: `[(i, p.text.strip()) for i, p in enumerate(doc.paragraphs) if p.text.strip()]`
|
||||||
|
3. **Check tables** separately: `doc.tables` — contracts often have signature blocks and SNS account tables
|
||||||
|
4. **Align comparison by clause semantics**, not paragraph index — different versions may have different paragraph counts
|
||||||
|
5. Also upload to Nextcloud for viewing in OnlyOffice
|
||||||
|
|
||||||
|
## Per-Run Precision for Tracked Changes (Maggie's correction)
|
||||||
|
|
||||||
|
When applying tracked changes based on comparison results, **never replace entire paragraphs**. Instead:
|
||||||
|
|
||||||
|
1. Identify the specific runs containing text to change
|
||||||
|
2. For each run: create w:del wrapping a deepcopy (converting w:t → w:delText), create w:ins with new text and cloned rPr, swap in place
|
||||||
|
3. All surrounding runs remain untouched
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Find specific run by text content
|
||||||
|
for r in para_element.findall(qn('w:r')):
|
||||||
|
text = ''.join(t.text or '' for t in r.findall(qn('w:t')))
|
||||||
|
if text == '¥700,000': # exact match on this run
|
||||||
|
r_parent = r.getparent()
|
||||||
|
r_idx = list(r_parent).index(r)
|
||||||
|
# Create del wrapping copy of this run
|
||||||
|
del_elem = make_del_run_from_existing(r)
|
||||||
|
# Create ins with new value, same rPr
|
||||||
|
ins_elem = make_ins_run('¥600,000', r.find(qn('w:rPr')))
|
||||||
|
r_parent.remove(r)
|
||||||
|
r_parent.insert(r_idx, ins_elem)
|
||||||
|
r_parent.insert(r_idx, del_elem)
|
||||||
|
break
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces clean tracked changes where Word/OnlyOffice shows exactly which characters changed (e.g., ~~700,000~~ → 600,000) rather than entire-paragraph replacements.
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# Multi-Version Creation Pattern
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
When creating multiple versions of the same contract (e.g., 版本1法定安排 vs 版本2反委托保护) or when needing to redo a version from scratch.
|
||||||
|
|
||||||
|
## Critical Rule
|
||||||
|
**ALWAYS start from the original source file for each version. Never modify a previously modified version.**
|
||||||
|
|
||||||
|
## Step-by-Step Pattern
|
||||||
|
|
||||||
|
### 1. Preserve Original Source
|
||||||
|
```python
|
||||||
|
# First time: copy original to safe location
|
||||||
|
shutil.copy('/path/to/original.docx', '/tmp/original_backup.docx')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. For Each Version, Start Fresh
|
||||||
|
```python
|
||||||
|
# Always reload from original
|
||||||
|
with zipfile.ZipFile('/tmp/original_backup.docx', 'r') as zin:
|
||||||
|
all_data = {n: zin.read(n) for n in zin.namelist()}
|
||||||
|
|
||||||
|
doc_xml = all_data['word/document.xml']
|
||||||
|
root = etree.fromstring(doc_xml)
|
||||||
|
body = root.find(f'{W}body')
|
||||||
|
paras = body.findall(f'{W}p')
|
||||||
|
|
||||||
|
# Get font template from original
|
||||||
|
rpr_template = None
|
||||||
|
for p in paras:
|
||||||
|
for r in p.findall(f'{W}r'):
|
||||||
|
t = r.find(f'{W}t')
|
||||||
|
if t is not None and t.text and t.text.strip():
|
||||||
|
rpr_elem = r.find(f'{W}rPr')
|
||||||
|
if rpr_elem is not None:
|
||||||
|
rpr_template = copy.deepcopy(rpr_elem)
|
||||||
|
break
|
||||||
|
if rpr_template:
|
||||||
|
break
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Apply All Modifications in One Pass
|
||||||
|
```python
|
||||||
|
rev_id = 1000 # Start fresh revision ID counter
|
||||||
|
|
||||||
|
# Batch all replacements
|
||||||
|
replacements = [
|
||||||
|
(0, "old text", "new text"),
|
||||||
|
(5, "old text", "new text"),
|
||||||
|
# ... more replacements
|
||||||
|
]
|
||||||
|
|
||||||
|
for idx, old_text, new_text in replacements:
|
||||||
|
p = paras[idx]
|
||||||
|
# Clear runs (but NOT comment anchors!)
|
||||||
|
for child in list(p):
|
||||||
|
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||||
|
if tag == 'r': # Only remove regular runs
|
||||||
|
p.remove(child)
|
||||||
|
d, i = make_tracked_replace(old_text, new_text, rpr_template, rev_id)
|
||||||
|
rev_id += 2
|
||||||
|
p.append(d)
|
||||||
|
p.append(i)
|
||||||
|
|
||||||
|
# Insert new clauses
|
||||||
|
insert_after = paras[10]
|
||||||
|
for clause_text in new_clauses:
|
||||||
|
new_p = make_ins_paragraph(clause_text, rpr_template, rev_id)
|
||||||
|
rev_id += 1
|
||||||
|
insert_after.addnext(new_p)
|
||||||
|
insert_after = new_p
|
||||||
|
|
||||||
|
# Mark deletions (e.g., 承诺书)
|
||||||
|
for idx in range(21, 30):
|
||||||
|
p = paras[idx]
|
||||||
|
text_parts = []
|
||||||
|
for r in p.findall(f'{W}r'):
|
||||||
|
t = r.find(f'{W}t')
|
||||||
|
if t is not None and t.text:
|
||||||
|
text_parts.append(t.text)
|
||||||
|
full_text = ''.join(text_parts)
|
||||||
|
if not full_text.strip():
|
||||||
|
continue
|
||||||
|
for child in list(p):
|
||||||
|
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||||
|
if tag == 'r':
|
||||||
|
p.remove(child)
|
||||||
|
del_elem = make_tracked_delete(full_text, rpr_template, rev_id)
|
||||||
|
rev_id += 1
|
||||||
|
p.append(del_elem)
|
||||||
|
|
||||||
|
# Add comments LAST (after all structural changes)
|
||||||
|
# Comment anchors are fragile - add them at the end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Add Comments Carefully
|
||||||
|
```python
|
||||||
|
# Check if paragraph already has comment anchors
|
||||||
|
existing = p.find(f'{W}commentRangeStart')
|
||||||
|
if existing is None:
|
||||||
|
# Add new comment anchors
|
||||||
|
comment_start = etree.Element(f'{W}commentRangeStart')
|
||||||
|
comment_start.set(f'{W}id', str(comment_id))
|
||||||
|
p.insert(0, comment_start)
|
||||||
|
|
||||||
|
comment_end = etree.Element(f'{W}commentRangeEnd')
|
||||||
|
comment_end.set(f'{W}id', str(comment_id))
|
||||||
|
p.append(comment_end)
|
||||||
|
|
||||||
|
comment_ref_run = etree.SubElement(p, f'{W}r')
|
||||||
|
comment_ref = etree.SubElement(comment_ref_run, f'{W}commentReference')
|
||||||
|
comment_ref.set(f'{W}id', str(comment_id))
|
||||||
|
|
||||||
|
# Update comments.xml
|
||||||
|
if 'word/comments.xml' in all_data:
|
||||||
|
croot = etree.fromstring(all_data['word/comments.xml'])
|
||||||
|
else:
|
||||||
|
croot = etree.Element(f'{W}comments', nsmap={'w': W_NS})
|
||||||
|
|
||||||
|
# Add or update comment
|
||||||
|
new_comment = etree.SubElement(croot, f'{W}comment')
|
||||||
|
new_comment.set(f'{W}id', str(comment_id))
|
||||||
|
new_comment.set(f'{W}author', author)
|
||||||
|
new_comment.set(f'{W}date', datetime.now().isoformat())
|
||||||
|
|
||||||
|
p = etree.SubElement(new_comment, f'{W}p')
|
||||||
|
r = etree.SubElement(p, f'{W}r')
|
||||||
|
t = etree.SubElement(r, f'{W}t')
|
||||||
|
t.set(XML_SPACE, 'preserve')
|
||||||
|
t.text = comment_text
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Save and Verify
|
||||||
|
```python
|
||||||
|
all_data['word/document.xml'] = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||||
|
all_data['word/comments.xml'] = etree.tostring(croot, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||||
|
|
||||||
|
with zipfile.ZipFile('/tmp/version1.docx', 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for name, data in all_data.items():
|
||||||
|
zout.writestr(name, data)
|
||||||
|
|
||||||
|
# Verify immediately
|
||||||
|
doc = Document('/tmp/version1.docx')
|
||||||
|
print(f"OK: {len(doc.paragraphs)} paragraphs")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### ❌ Don't Do This
|
||||||
|
```python
|
||||||
|
# WRONG: Modifying v1 to create v2
|
||||||
|
shutil.copy('/tmp/v1.docx', '/tmp/v2.docx')
|
||||||
|
with zipfile.ZipFile('/tmp/v2.docx', 'r') as zin:
|
||||||
|
# ... load v1's modified structure
|
||||||
|
# This will have v1's tracked changes, comments, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ Don't Clear Everything When Modifying
|
||||||
|
```python
|
||||||
|
# WRONG: Clears comment anchors too!
|
||||||
|
for child in list(p):
|
||||||
|
if child.tag not in (f'{W}pPr',):
|
||||||
|
p.remove(child) # Removes commentRangeStart/End!
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ Do This Instead
|
||||||
|
```python
|
||||||
|
# RIGHT: Only clear regular runs, preserve comment anchors
|
||||||
|
for child in list(p):
|
||||||
|
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||||
|
if tag == 'r': # Only regular runs
|
||||||
|
p.remove(child)
|
||||||
|
# commentRangeStart, commentRangeEnd are preserved
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preserving Original Comments
|
||||||
|
When the original document has comments (e.g., Alice, 法务, 杜律), the workflow must:
|
||||||
|
1. Read original comments.xml to get all comment IDs and content
|
||||||
|
2. Check which paragraphs have comment anchors (commentRangeStart/End)
|
||||||
|
3. When clearing runs, preserve comment anchors (they're not `w:r` elements)
|
||||||
|
4. Add new comments with NEW IDs (don't reuse original IDs)
|
||||||
|
5. Original comments remain unchanged in comments.xml
|
||||||
|
|
||||||
|
## Preserving Third-Party Tracked Changes (2026-07-01 华诚-Z案)
|
||||||
|
|
||||||
|
When a contract file contains tracked changes from someone other than WB (e.g., 华诚-Z, Crystall, or any third-party reviewer), **those files must never be overwritten**. The tracked changes represent real editorial work that cannot be reconstructed from session notes alone.
|
||||||
|
|
||||||
|
### Backup Protocol
|
||||||
|
```python
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# BEFORE any modification to a file with third-party tracked changes:
|
||||||
|
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
backup_path = f'/tmp/{filename}.bak_{ts}'
|
||||||
|
shutil.copy(source_path, backup_path)
|
||||||
|
print(f"Backed up to {backup_path}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Detection: Does This File Have Third-Party Changes?
|
||||||
|
```python
|
||||||
|
import zipfile, re
|
||||||
|
with zipfile.ZipFile(filepath) as z:
|
||||||
|
content = z.read('word/document.xml').decode('utf-8', errors='ignore')
|
||||||
|
authors = set(re.findall(r'w:author="([^"]+)"', content))
|
||||||
|
third_party = authors - {'WB'}
|
||||||
|
if third_party:
|
||||||
|
print(f"⚠️ Third-party authors found: {third_party} — BACKUP REQUIRED")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Version with Third-Party Edits
|
||||||
|
When creating v1 and v2 from a file that has both original content AND third-party edits:
|
||||||
|
|
||||||
|
1. **Backup the file with third-party edits** (e.g., `华诚-Z版.bak_20260701`)
|
||||||
|
2. **Backup the pristine original** (no tracked changes at all)
|
||||||
|
3. **For each version**: start from the pristine original, then layer on:
|
||||||
|
- WB's own tracked changes
|
||||||
|
- Third-party's tracked changes (with author renamed to WB)
|
||||||
|
4. **Never modify the backup files** — they are your insurance
|
||||||
|
|
||||||
|
### What Was Lost (华诚-Z案)
|
||||||
|
- 华诚-Z made 3 tracked changes in OnlyOffice: 第六条 (removed specific legal citations), 第七条 (simplified correction process), 第八条 (added employee return placement clause)
|
||||||
|
- These intermediate files in /tmp/ were overwritten during v1/v2 creation
|
||||||
|
- Only session notes preserved the *content* of changes, not the actual tracked change markup (ids, timestamps, exact XML positions)
|
||||||
|
- **Recovery was impossible** — Doro had to accept reconstructed versions
|
||||||
|
|
||||||
|
## Real Example from This Session
|
||||||
|
- Original: 4 comments (Alice×2, 法务, 杜律)
|
||||||
|
- Version 1: 5 comments (original 4 + WB legal risk)
|
||||||
|
- Version 2: 5 comments (original 4 + WB legal risk)
|
||||||
|
|
||||||
|
Both versions created independently from original, each with their own WB comment (different content for each version).
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 新增条款pPr完整克隆铁律(2026-07-13 盈浦健康科普合同教训)
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
新增条款(如"八、转包与分包"的正文P75)插入后,只考虑了numPr是否正确,但遗漏了其他pPr子元素(如`ind`首行缩进)。导致新增段落与原文同类段落格式不一致。
|
||||||
|
|
||||||
|
## 教训链(同一份合同被Doro纠正3次)
|
||||||
|
|
||||||
|
1. **第一次**:P75挂了错误的numId=11(不可抗力的序列)→ 渲染为"3."
|
||||||
|
2. **第二次**:去掉numId后,加了新的numId=14 → 单段正文不该有编号("有2才有1"规则延伸到numPr)
|
||||||
|
3. **第三次**:去掉numId后仍缺首行缩进ind=420 → 与原文"一、合作背景"(同为单段无编号正文)格式不一致
|
||||||
|
|
||||||
|
## 铁律:新增段落pPr必须完整比对原文参照段
|
||||||
|
|
||||||
|
动手前必须:
|
||||||
|
1. **找到原文中的参照段落**——格式相同的段落(同层级、同类型)
|
||||||
|
2. **逐子元素列出参照段的pPr**:spacing、ind、numPr、jc、每一个子元素
|
||||||
|
3. **逐一对比新增段落的pPr**:缺什么补什么,多什么删什么
|
||||||
|
4. 不能只看一个属性(如numPr)就认为"格式正确了"
|
||||||
|
|
||||||
|
## "有2才有1"规则在numPr上的延伸
|
||||||
|
|
||||||
|
原规则:新增条款如果下一级只有一条内容,不加子编号。
|
||||||
|
|
||||||
|
延伸到numPr:如果某章节下只有一个正文段落,且原文中同类单段正文没有numPr(如"一、合作背景"的P12无numPr),则新增的单段正文也不加numPr。
|
||||||
|
|
||||||
|
判断方法:看原文中有没有"章节标题+单段正文+无numPr"的先例。有→新增单段也不加。
|
||||||
|
|
||||||
|
## 检查清单(操作后必过)
|
||||||
|
|
||||||
|
- [ ] 新增段落的spacing与参照段一致
|
||||||
|
- [ ] 新增段落的ind与参照段一致(特别是firstLine/firstLineChars)
|
||||||
|
- [ ] 新增段落的numPr:单段→不加(参照原文同类);多段→加入对应序列
|
||||||
|
- [ ] 新增段落INS run的rPr与参照段原文run的rPr一致(无多余sz、无缺失属性)
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Strip numPr When Adding Manual Numbering to Auto-Numbered Paragraphs
|
||||||
|
|
||||||
|
## Problem (2026-07-01 反委托代发工资协议)
|
||||||
|
|
||||||
|
Original contract paragraphs have `<w:numPr>` with actual auto-numbering (e.g., `numId=3 → abstractNum decimal "%1." start=1`). When you insert manual "第X条" numbering as `w:ins` at paragraph start, OnlyOffice renders BOTH:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 第一条 乙方应严格按照... ← "1." is auto-numbering, "第一条" is your INS
|
||||||
|
```
|
||||||
|
|
||||||
|
This looks broken — two different numbering systems stacked.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
The paragraph's `pPr/numPr` tells the rendering engine to prepend an automatic decimal number. Your INS adds a second, manual number. They coexist independently.
|
||||||
|
|
||||||
|
Additionally, if the paragraph has `pPr/pPrChange` (tracking the old paragraph formatting), the old `numPr` inside `pPrChange` can ALSO render in markup view.
|
||||||
|
|
||||||
|
## Fix (two-step)
|
||||||
|
|
||||||
|
After inserting manual numbering INS elements, strip auto-numbering from ALL affected paragraphs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
for idx in target_paragraph_indices:
|
||||||
|
p = paras[idx]
|
||||||
|
ppr = p.find(f'{WNS}pPr')
|
||||||
|
if ppr is not None:
|
||||||
|
# Step 1: Remove direct numPr
|
||||||
|
num_pr = ppr.find(f'{WNS}numPr')
|
||||||
|
if num_pr is not None:
|
||||||
|
ppr.remove(num_pr)
|
||||||
|
|
||||||
|
# Step 2: Remove numPr inside pPrChange (old formatting record)
|
||||||
|
ppr_change = ppr.find(f'{WNS}pPrChange')
|
||||||
|
if ppr_change is not None:
|
||||||
|
inner_ppr = ppr_change.find(f'{WNS}pPr')
|
||||||
|
if inner_ppr is not None:
|
||||||
|
inner_num = inner_ppr.find(f'{WNS}numPr')
|
||||||
|
if inner_num is not None:
|
||||||
|
inner_ppr.remove(inner_num)
|
||||||
|
```
|
||||||
|
|
||||||
|
## When This Applies
|
||||||
|
|
||||||
|
- You're converting a contract from auto-numbered clauses to manual "第X条" heading-style numbering
|
||||||
|
- The original .doc/.docx used Word's list numbering for clause structure
|
||||||
|
- You're adding "第一条 " etc. as INS at paragraph start
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After fix:
|
||||||
|
1. `pdftotext -layout` of OnlyOffice render should show NO stray "1." / "2." / "3." before your "第X条"
|
||||||
|
2. Accept-revisions preview should also be clean (no residual auto-numbers)
|
||||||
|
|
||||||
|
## Scenario B: Auto-Numbering Resets Across Tracked-Deleted Paragraphs (2026-07-01 反委托代发工资协议)
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
When paragraphs with `numPr` auto-numbering are interspersed with **entirely deleted paragraphs** (all content in `w:del`), OnlyOffice's auto-number counter **resets to 1** after the deleted block. This makes continuous numbering impossible with `numPr` alone.
|
||||||
|
|
||||||
|
Example structure:
|
||||||
|
```
|
||||||
|
P6: numPr=1 INS content (clause 1) → renders "1."
|
||||||
|
P7: numPr=1 continuation → renders "2." (wrong if P7 shouldn't be numbered)
|
||||||
|
P8: numPr=1 ALL w:del → renders "3." with strikethrough
|
||||||
|
P9: numPr=1 ALL w:del → renders "4." with strikethrough
|
||||||
|
P10: numPr=1 INS content (clause 2) → renders "1." ← RESETS! Should be "2."
|
||||||
|
```
|
||||||
|
|
||||||
|
The auto-numbering engine counts visible (non-deleted) items in the `numId` sequence, but deleted paragraphs **break the continuity** in OnlyOffice's rendering.
|
||||||
|
|
||||||
|
### Solution: Convert to Manual Text Numbering
|
||||||
|
|
||||||
|
Strip `numPr` from ALL paragraphs and insert "N. " as `w:ins` text at paragraph start. This gives identical visual output ("1. 2. 3. ...") without depending on the broken auto-number counter.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Step 1: Strip ALL numPr (including inside pPrChange)
|
||||||
|
for i, p in enumerate(paragraphs):
|
||||||
|
pPr = p.find(f'{{{W}}}pPr')
|
||||||
|
if pPr is not None:
|
||||||
|
numPr = pPr.find(f'{{{W}}}numPr')
|
||||||
|
if numPr is not None:
|
||||||
|
pPr.remove(numPr)
|
||||||
|
for pPrChange in pPr.findall(f'{{{W}}}pPrChange'):
|
||||||
|
old_pPr = pPrChange.find(f'{{{W}}}pPr')
|
||||||
|
if old_pPr is not None:
|
||||||
|
old_numPr = old_pPr.find(f'{{{W}}}numPr')
|
||||||
|
if old_numPr is not None:
|
||||||
|
old_pPr.remove(old_numPr)
|
||||||
|
|
||||||
|
# Step 2: Insert "N. " as w:ins text for each clause paragraph
|
||||||
|
# Only number paragraphs that have VISIBLE content (not entirely w:del)
|
||||||
|
clause_map = {6: 1, 10: 2, 11: 3, ...} # para_index: clause_number
|
||||||
|
|
||||||
|
for para_idx, clause_num in clause_map.items():
|
||||||
|
p = paragraphs[para_idx]
|
||||||
|
# Build INS element with "N. " text
|
||||||
|
ins_elem = ET.Element(f'{{{W}}}ins')
|
||||||
|
ins_elem.set(f'{{{W}}}id', str(next_rev_id()))
|
||||||
|
ins_elem.set(f'{{{W}}}author', rev_author) # from existing INS in doc
|
||||||
|
ins_elem.set(f'{{{W}}}date', rev_date)
|
||||||
|
|
||||||
|
r_elem = ET.SubElement(ins_elem, f'{{{W}}}r')
|
||||||
|
# Clone rPr from existing runs for font consistency
|
||||||
|
rPr = get_run_rPr_from_paragraph(p)
|
||||||
|
if rPr is not None:
|
||||||
|
r_elem.append(copy.deepcopy(rPr))
|
||||||
|
|
||||||
|
t_elem = ET.SubElement(r_elem, f'{{{W}}}t')
|
||||||
|
t_elem.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||||
|
t_elem.text = f"{clause_num}. "
|
||||||
|
|
||||||
|
# Insert after pPr
|
||||||
|
pPr = p.find(f'{{{W}}}pPr')
|
||||||
|
if pPr is not None:
|
||||||
|
p.insert(list(p).index(pPr) + 1, ins_elem)
|
||||||
|
else:
|
||||||
|
p.insert(0, ins_elem)
|
||||||
|
```
|
||||||
|
|
||||||
|
### When to Use This (vs Scenario A)
|
||||||
|
|
||||||
|
- **Scenario A** (above): You're CHANGING the numbering scheme (auto "1." → manual "第一条")
|
||||||
|
- **Scenario B** (this): You're KEEPING the same format ("1. 2. 3.") but converting from auto to manual because auto-numbering resets across w:del paragraphs
|
||||||
|
- **Trigger**: Original uses numPr auto-numbering + your edits create entirely-deleted paragraphs between numbered items → auto counter resets → switch to manual text
|
||||||
|
|
||||||
|
### Key Decision: Which Paragraphs to Number
|
||||||
|
|
||||||
|
Only number paragraphs that will be visible after accepting revisions:
|
||||||
|
- Paragraphs with ONLY `w:del` content → skip (they're deleted)
|
||||||
|
- Paragraphs that are continuations of the previous clause (no independent number) → skip
|
||||||
|
- New INS-only paragraphs (new clauses) → number them
|
||||||
|
- Rewritten paragraphs (mixed INS+DEL, first clause in sequence) → number them
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
After conversion:
|
||||||
|
1. OnlyOffice render (x2t → PDF) should show continuous "1. 2. 3. ... 11." without resets
|
||||||
|
2. No stray auto-numbers from numPr remnants
|
||||||
|
3. Deleted paragraphs (entirely w:del) should NOT show any number
|
||||||
|
|
||||||
|
## Distinction from Existing Rules
|
||||||
|
|
||||||
|
- Rule 5 (A類 vs B類) talks about NEW clauses inheriting/stripping numPr
|
||||||
|
- Scenario A is EXISTING paragraphs where you're REPLACING their numbering scheme with a different format via INS
|
||||||
|
- Scenario B is EXISTING paragraphs where auto-numbering BREAKS due to tracked-deleted paragraphs, requiring conversion to same-format manual text
|
||||||
|
- numId=0 trap (Rule 5 sub-note) is about fake auto-numbering; BOTH scenarios here are about REAL auto-numbering that renders visible numbers
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 一页纸 docx 排版压缩配方
|
||||||
|
|
||||||
|
适用场景:创建必须严格一页的 Word 文档(合作框架、报价单、一页摘要等),通过 OnlyOffice x2t 渲染验证。
|
||||||
|
|
||||||
|
## 迭代压缩流程
|
||||||
|
|
||||||
|
x2t 渲染的行高/间距比 python-docx 估算的略宽松,不能靠"调好参数直接交付"。必须走渲染验证循环。
|
||||||
|
|
||||||
|
### 第一轮:合理起点
|
||||||
|
| 参数 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 上下边距 | 1.5–2.0 cm |
|
||||||
|
| 左右边距 | 1.8–2.0 cm |
|
||||||
|
| 正文字号 | 9–10 pt |
|
||||||
|
| 表格字号 | 8–9 pt |
|
||||||
|
| 行距 | 1.05–1.15 |
|
||||||
|
|
||||||
|
### 验证循环
|
||||||
|
```bash
|
||||||
|
bash ~/.hermes/skills/legal/contract-editor/scripts/onlyoffice-render.sh <docx> <pdf>
|
||||||
|
python3 -c "
|
||||||
|
import subprocess
|
||||||
|
r=subprocess.run(['pdftotext','<pdf>','-'],capture_output=True,text=True)
|
||||||
|
pages=r.stdout.split('\f')
|
||||||
|
print(f'页数: {len(pages)}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
- 页数=1 → 交付
|
||||||
|
- 页数=2 且末页空白 → 内容刚好溢出,微调即可
|
||||||
|
- 页数=2 且有内容 → 需要大幅压缩或精简文字
|
||||||
|
|
||||||
|
### 逐级压缩(按优先级)
|
||||||
|
1. 底部边距:1.5→1.0→0.8→0.5 cm(先砍底部,顶部保阅读感)
|
||||||
|
2. 表格字号:8→7.5→7 pt
|
||||||
|
3. 行距:1.05→1.0
|
||||||
|
4. 左右边距:1.8→1.5 cm
|
||||||
|
5. 段落间距:Pt(2)→Pt(1)→Pt(0)
|
||||||
|
6. 精简文字(最后手段)
|
||||||
|
|
||||||
|
### 已实证的可用参数(5000字内一页A4)
|
||||||
|
| 参数 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 上下边距 | 1.2 / 0.5 cm |
|
||||||
|
| 左右边距 | 1.5 cm |
|
||||||
|
| 正文字号 | 8 pt |
|
||||||
|
| 表格字号 | 7 pt |
|
||||||
|
| 行距 | 1.0 |
|
||||||
|
| 段落间距 | 0 |
|
||||||
|
|
||||||
|
## 陷阱
|
||||||
|
- x2t 对表格行高估算偏大,表内文字多时尤其明显
|
||||||
|
- 分隔线(`—`*N)占用空间,一页紧张时去掉
|
||||||
|
- 表格 `Table Grid` 样式自带内边距,无法通过 python-docx 参数完全消除
|
||||||
|
- 第二页空白但无文字 = 内容刚好溢出几像素,再砍 0.2cm 底部边距或减 0.5pt 字号即可
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# ContractEditor Operation Ordering: Add Clauses Before Renumbering
|
||||||
|
|
||||||
|
## 2026-07-13 练塘硬件购销合同教训
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
When interleaving `add_clause_before()` and `tracked_replace()` for renumbering, lxml throws:
|
||||||
|
```
|
||||||
|
ValueError: Element is not a child of this node.
|
||||||
|
```
|
||||||
|
in `tracked_replace()` → `parent.remove(runs[idx])`.
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
`add_clause_before()` / `add_clause()` mutate the XML tree (insert new `<w:p>` elements). After insertion, previously-found element references held by later `tracked_replace()` calls may point to nodes whose parent relationship has shifted. The `parent.remove()` call inside `tracked_replace` fails because the run's parent `<w:p>` is no longer the same object the code expects.
|
||||||
|
|
||||||
|
### Fix: Two-Phase Approach (铁律)
|
||||||
|
|
||||||
|
**Phase 1 — All structural additions:**
|
||||||
|
- `add_clause()` / `add_clause_before()` for new clauses
|
||||||
|
- Content-level `tracked_replace()` that don't touch clause titles being renumbered
|
||||||
|
|
||||||
|
**Phase 2 — Renumbering (after all additions are done):**
|
||||||
|
- `tracked_replace('第七条不可抗力', '第九条不可抗力')` etc.
|
||||||
|
|
||||||
|
### Example (correct order)
|
||||||
|
```python
|
||||||
|
# Phase 1: add new clauses
|
||||||
|
editor.add_clause_before('第七条 转包与分包\n...', before_search='第七条不可抗力')
|
||||||
|
editor.add_clause_before('第八条 第三方侵权\n...', before_search='第七条不可抗力')
|
||||||
|
|
||||||
|
# Phase 2: renumber old clauses (all additions done)
|
||||||
|
editor.tracked_replace('第七条不可抗力', '第九条不可抗力')
|
||||||
|
editor.tracked_replace('第八条争议解决', '第十条争议解决')
|
||||||
|
```
|
||||||
|
|
||||||
|
### WPS/DOC File Handling
|
||||||
|
WPS `.wps` and `.doc` files must be converted to `.docx` before ContractEditor can process them:
|
||||||
|
```bash
|
||||||
|
libreoffice --headless --convert-to docx input.wps --outdir /tmp/contract-review/
|
||||||
|
```
|
||||||
|
Then copy to a simple ASCII filename to avoid python-docx path issues with Chinese characters.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Paragraph Deletion via Tracked Changes (WB)
|
||||||
|
|
||||||
|
When a reviewer instructs you to delete an entire clause/paragraph, use **paragraph-level deletion** — not just deleting the text but marking the entire paragraph as removed in tracked changes.
|
||||||
|
|
||||||
|
## Two-Part Deletion
|
||||||
|
|
||||||
|
### Part 1: Paragraph Mark Deletion
|
||||||
|
|
||||||
|
Add a `w:del` element inside the paragraph's `w:pPr/w:rPr`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<w:pPr>
|
||||||
|
<w:rPr>
|
||||||
|
<w:del w:id="7777" w:author="WB" w:date="2026-06-26T00:00:00Z"/>
|
||||||
|
</w:rPr>
|
||||||
|
</w:pPr>
|
||||||
|
```
|
||||||
|
|
||||||
|
This marks the paragraph marker (¶) as deleted, so the paragraph doesn't leave an empty line.
|
||||||
|
|
||||||
|
### Part 2: Content Deletion
|
||||||
|
|
||||||
|
Wrap every text run in the paragraph inside `w:del` elements, converting `w:t` to `w:delText`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
for child in list(paragraph):
|
||||||
|
tag = child.tag.split('}')[-1]
|
||||||
|
if tag in ('r', 'ins'):
|
||||||
|
paragraph.remove(child)
|
||||||
|
del_elem = etree.SubElement(paragraph, f'{{{W}}}del')
|
||||||
|
del_elem.set(f'{{{W}}}id', '7777')
|
||||||
|
del_elem.set(f'{{{W}}}author', 'WB')
|
||||||
|
del_elem.set(f'{{{W}}}date', '2026-06-26T00:00:00Z')
|
||||||
|
|
||||||
|
target_runs = child.findall(f'{{{W}}}r') if tag == 'ins' else [child]
|
||||||
|
for r in target_runs:
|
||||||
|
t = r.find(f'{{{W}}}t')
|
||||||
|
if t is not None:
|
||||||
|
r.remove(t)
|
||||||
|
dt = etree.SubElement(r, f'{{{W}}}delText')
|
||||||
|
dt.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||||
|
dt.text = t.text
|
||||||
|
r.set(f'{{{W}}}rsidDel', new_rsid())
|
||||||
|
del_elem.append(r)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Points
|
||||||
|
|
||||||
|
- **Same del id** for both the paragraph mark and content dels (e.g., `7777`) — they're part of the same deletion operation
|
||||||
|
- **Handle existing INS runs**: If the paragraph has runs inside `w:ins` (from previous revisions), extract them and wrap in `w:del` too
|
||||||
|
- **Leave existing DEL runs untouched** — they're already deleted
|
||||||
|
- **Copy rPr**: If the original run had `rPr`, copy it into the del run so the strikethrough text renders with correct font/size
|
||||||
|
- **Use unique ids**: Pick an id that doesn't collide with existing del/ins ids in the document. Check `max(del_ids) + 1000` if unsure
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
After deletion, render with OnlyOffice and check:
|
||||||
|
1. The deleted paragraph appears with strikethrough in markup view
|
||||||
|
2. Accepting all revisions removes the paragraph entirely (no empty line)
|
||||||
|
3. The paragraph mark (¶) is also deleted — no gap between surrounding paragraphs
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Per-Paragraph Font Matching(2026-07-13 消防设施检测合同教训)
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
同一份合同中,不同段落的原文runs可能有完全不同的字体属性方案:
|
||||||
|
- P20 runs: `ascii=宋体, hAnsi=宋体, cs=宋体, sz=24, eastAsia=None, hint=None`
|
||||||
|
- P36/P41 runs: `rPr=None`(完全无字体属性,靠docDefaults/style继承)
|
||||||
|
|
||||||
|
如果对所有WB INS runs统一设置一种字体属性,必然导致某些段落mismatch。
|
||||||
|
|
||||||
|
## 错误做法
|
||||||
|
```python
|
||||||
|
# ❌ 全局统一设置
|
||||||
|
for ins in all_wb_ins:
|
||||||
|
rf.set('ascii', '宋体')
|
||||||
|
rf.set('sz', '24')
|
||||||
|
```
|
||||||
|
|
||||||
|
## 正确做法
|
||||||
|
```python
|
||||||
|
# ✅ 按段落匹配原文第一个非INS/非DEL run的rPr
|
||||||
|
for p in paras:
|
||||||
|
# 找到同段落的第一个orig run
|
||||||
|
orig_run = None
|
||||||
|
for child in p:
|
||||||
|
if child.tag == f'{WNS}r':
|
||||||
|
orig_run = child
|
||||||
|
break
|
||||||
|
|
||||||
|
if orig_run is None:
|
||||||
|
continue # 整段INS,无参照
|
||||||
|
|
||||||
|
orig_rpr = orig_run.find(f'{WNS}rPr')
|
||||||
|
orig_rf = orig_rpr.find(f'{WNS}rFonts') if orig_rpr is not None else None
|
||||||
|
|
||||||
|
# INS的rPr应该与orig_run的rPr完全匹配
|
||||||
|
# 如果orig没有rFonts → INS也不该有
|
||||||
|
# 如果orig有ascii=宋体但没有eastAsia → INS也是这样
|
||||||
|
```
|
||||||
|
|
||||||
|
## 整段INS段落(无同段原文可比)
|
||||||
|
- wb-ins-font-verify.py会报"MISSING HINT (无同段原文可比)"
|
||||||
|
- 这是**已知假阳性**,不算真问题
|
||||||
|
- 整段INS段落的字体应参照**相邻段落**(前后各2段)的原文run格式
|
||||||
|
- 如果相邻原文runs有explicit属性(ascii=宋体 sz=24),INS也设
|
||||||
|
- 如果相邻原文runs无rPr,INS也不设
|
||||||
|
|
||||||
|
## 2026-07-13 消防设施检测合同实证
|
||||||
|
- 第一次修复:全局strip eastAsia/hint → P20报ASCII MISMATCH(原文有ascii=宋体)
|
||||||
|
- 第二次修复:全局设ascii=宋体 → P36/P41报ASCII MISMATCH(原文无rFonts)
|
||||||
|
- 正确修复:per-paragraph检查orig_run是否有ascii → 有则INS也设,无则INS也不设
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 反委托代发工资法律风险
|
||||||
|
|
||||||
|
## 核心法律规定
|
||||||
|
|
||||||
|
| 法律依据 | 条文 | 效力 |
|
||||||
|
|----------|------|------|
|
||||||
|
| 《劳务派遣暂行规定》第8条第(三)项 | 派遣单位应当依法支付被派遣劳动者的劳动报酬 | 强制性规定 |
|
||||||
|
| 《劳动合同法》第58条 | 派遣单位是用人单位,应履行用人单位义务 | 法律 |
|
||||||
|
| 《劳动合同法》第92条第2款 | 用工单位给被派遣劳动者造成损害的,派遣单位与用工单位承担连带赔偿责任 | 法律 |
|
||||||
|
| 《劳务派遣暂行规定》第24条 | 用工单位违法退回的,按劳动合同法第92条第2款执行 | 部门规章 |
|
||||||
|
| 劳社部发〔2005〕12号第2条 | 工资支付凭证是认定事实劳动关系的首要证据 | 规范性文件 |
|
||||||
|
|
||||||
|
## 核心结论
|
||||||
|
|
||||||
|
1. **代发工资 = 事实劳动关系首要证据**:用工单位直接向派遣员工发工资,违反《劳务派遣暂行规定》第8条强制性规定
|
||||||
|
2. **协议不能免除法定责任**:甲乙之间的内部追偿条款不能对抗劳动者和行政机关
|
||||||
|
3. **退回条款限制**:用工单位只能在法定三种情形下退回(客观情况重大变化/经济性裁员、破产/解散、协议期满)
|
||||||
|
4. **"与甲方无涉"条款有法律风险**:可能因违反《劳动合同法》第26条第2款(免除法定责任)被认定无效
|
||||||
|
|
||||||
|
## 关键判例
|
||||||
|
|
||||||
|
- **广东高院(2022)粤民再30号**:汽车公司以咨询公司名义签劳动合同,工资由汽车公司直接发放。认定汽车公司与劳动者存在事实劳动关系。
|
||||||
|
- **(2019)沪0109民初12453号**:用工单位违法退回导致派遣公司违法解除的,用工单位承担连带赔偿责任。
|
||||||
|
- **(2022)鲁0322民初834号**:甲公司将工资计算后交乙公司发放,法院认定甲公司存在经济依附性,构成事实劳动关系。
|
||||||
|
|
||||||
|
## 审查建议
|
||||||
|
|
||||||
|
### 推荐方案(版本1:回归法定安排)
|
||||||
|
- 删除代发工资条款,由乙方(派遣公司)直接支付工资
|
||||||
|
- 添加甲方监督权、扣款权、违约金条款
|
||||||
|
- 添加乙方资质维持、用工管理义务、退回权、保密条款
|
||||||
|
|
||||||
|
### 替代方案(版本2:反委托保护)
|
||||||
|
如甲方坚持代发,最大化保护措施:
|
||||||
|
1. 鉴于条款定性为"委托代发",明确甲方仅为代理人
|
||||||
|
2. 三方签署要求(甲方、乙方、派遣员工)
|
||||||
|
3. 事实劳动关系兜底:乙方十日内赔偿甲方全部损失
|
||||||
|
4. 履约保证金(金额留空)或银行保函
|
||||||
|
5. 税务责任限定:甲方仅承担自身原因导致的差额
|
||||||
|
6. 社保义务对等
|
||||||
|
7. 劳动关系确认条款
|
||||||
|
8. 乙方资质维持 + 用工管理义务
|
||||||
|
|
||||||
|
### 退回条款措辞
|
||||||
|
❌ "退回派遣员工由乙方依法自行安置处理,与甲方无涉"(有法律风险)
|
||||||
|
✅ "派遣员工退回后由乙方依法负责安置处理。因乙方安置不当导致甲方被追究责任的,乙方应赔偿甲方因此遭受的全部损失。"
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# 审查意见文档字体强制设置
|
||||||
|
|
||||||
|
## 背景(2026-07-02 肃言+恭兴合同返工)
|
||||||
|
|
||||||
|
review-rules.md 规定审查意见文档:中文统一仿宋体,英文Times New Roman。
|
||||||
|
|
||||||
|
**问题**:模板文件(`朱家角 审查意见【模板】.docx`)的表头行有显式eastAsia=仿宋,但新建的数据行字体设置不一致:
|
||||||
|
- 恭兴审查意见:editor给数据行设了 ascii=仿宋 hAnsi=仿宋(错:英文也变仿宋了)
|
||||||
|
- 肃言审查意见:editor压根没给数据行设ascii/hAnsi(只有eastAsia=仿宋)
|
||||||
|
|
||||||
|
**根因**:LLM每次独立session生成代码,字体设置逻辑不稳定。
|
||||||
|
|
||||||
|
## 强制修复代码(生成审查意见后必跑)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from docx import Document
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
from docx.oxml import OxmlElement
|
||||||
|
|
||||||
|
def enforce_review_opinion_fonts(doc_path, save=True):
|
||||||
|
"""审查意见文档生成后强制设置所有run的字体。
|
||||||
|
中文=仿宋, 英文=Times New Roman
|
||||||
|
"""
|
||||||
|
doc = Document(doc_path)
|
||||||
|
fixed = 0
|
||||||
|
|
||||||
|
# Fix all table cells
|
||||||
|
for table in doc.tables:
|
||||||
|
for row in table.rows:
|
||||||
|
for cell in row.cells:
|
||||||
|
for p in cell.paragraphs:
|
||||||
|
for run in p.runs:
|
||||||
|
fixed += _fix_run_font(run._element)
|
||||||
|
|
||||||
|
# Fix all paragraphs outside tables
|
||||||
|
for p in doc.paragraphs:
|
||||||
|
for run in p.runs:
|
||||||
|
fixed += _fix_run_font(run._element)
|
||||||
|
|
||||||
|
if save:
|
||||||
|
doc.save(doc_path)
|
||||||
|
return fixed
|
||||||
|
|
||||||
|
def _fix_run_font(run_element):
|
||||||
|
"""Ensure run has eastAsia=仿宋, ascii/hAnsi=Times New Roman"""
|
||||||
|
rPr = run_element.find(qn('w:rPr'))
|
||||||
|
if rPr is None:
|
||||||
|
rPr = OxmlElement('w:rPr')
|
||||||
|
run_element.insert(0, rPr)
|
||||||
|
|
||||||
|
rFonts = rPr.find(qn('w:rFonts'))
|
||||||
|
if rFonts is None:
|
||||||
|
rFonts = OxmlElement('w:rFonts')
|
||||||
|
rPr.insert(0, rFonts)
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
# eastAsia must be 仿宋
|
||||||
|
if rFonts.get(qn('w:eastAsia')) != '仿宋':
|
||||||
|
rFonts.set(qn('w:eastAsia'), '仿宋')
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# ascii must be Times New Roman (NOT 仿宋)
|
||||||
|
if rFonts.get(qn('w:ascii')) != 'Times New Roman':
|
||||||
|
rFonts.set(qn('w:ascii'), 'Times New Roman')
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# hAnsi must be Times New Roman (NOT 仿宋)
|
||||||
|
if rFonts.get(qn('w:hAnsi')) != 'Times New Roman':
|
||||||
|
rFonts.set(qn('w:hAnsi'), 'Times New Roman')
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
return 1 if changed else 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证方法
|
||||||
|
|
||||||
|
```python
|
||||||
|
def verify_review_opinion_fonts(doc_path):
|
||||||
|
"""验证审查意见文档字体全部正确"""
|
||||||
|
from docx import Document
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
doc = Document(doc_path)
|
||||||
|
errors = []
|
||||||
|
for table in doc.tables:
|
||||||
|
for i, row in enumerate(table.rows):
|
||||||
|
for j, cell in enumerate(row.cells):
|
||||||
|
for p in cell.paragraphs:
|
||||||
|
for run in p.runs:
|
||||||
|
rpr = run._element.find(qn('w:rPr'))
|
||||||
|
if rpr is None:
|
||||||
|
errors.append(f'Row{i}Col{j}: no rPr')
|
||||||
|
continue
|
||||||
|
rf = rpr.find(qn('w:rFonts'))
|
||||||
|
if rf is None:
|
||||||
|
errors.append(f'Row{i}Col{j}: no rFonts')
|
||||||
|
continue
|
||||||
|
ea = rf.get(qn('w:eastAsia'))
|
||||||
|
ascii_f = rf.get(qn('w:ascii'))
|
||||||
|
hAnsi = rf.get(qn('w:hAnsi'))
|
||||||
|
if ea != '仿宋':
|
||||||
|
errors.append(f'Row{i}Col{j}: eastAsia={ea} (should be 仿宋)')
|
||||||
|
if ascii_f != 'Times New Roman':
|
||||||
|
errors.append(f'Row{i}Col{j}: ascii={ascii_f} (should be TNR)')
|
||||||
|
if hAnsi != 'Times New Roman':
|
||||||
|
errors.append(f'Row{i}Col{j}: hAnsi={hAnsi} (should be TNR)')
|
||||||
|
return errors
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见错误
|
||||||
|
|
||||||
|
| 错误 | 后果 | 根因 |
|
||||||
|
|------|------|------|
|
||||||
|
| ascii/hAnsi=仿宋 | 英文/数字渲染用仿宋(无西文字形,显示异常) | LLM把"统一仿宋"理解为所有属性都设仿宋 |
|
||||||
|
| 数据行无ascii/hAnsi | 英文回退系统默认字体(可能是宋体/黑体) | 只设了eastAsia,忘设西文字体 |
|
||||||
|
| 只有表头有字体 | 数据行全部回退默认 | 模板限制:只有表头行有显式字体 |
|
||||||
|
|
||||||
|
## 预防方案
|
||||||
|
|
||||||
|
最佳方案:修改模板文件的**Normal样式或Table Grid样式**定义,预设完整字体。但模板可能被多场景共用,最稳妥还是生成后强制设置。
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 审查意见文档格式检查清单 (2026-07-13 Doro纠正)
|
||||||
|
|
||||||
|
## 生成后必做三项格式修复
|
||||||
|
|
||||||
|
### 1. 删除表格中的空白行
|
||||||
|
- "空白行"指**表格中**三列全为空的row(模板占位行)
|
||||||
|
- 不是文档段落的空行
|
||||||
|
- 代码:遍历table rows,检查所有cells文本为空的row,删除
|
||||||
|
|
||||||
|
### 2. 页眉日期改为修订当日
|
||||||
|
- 读取 header*.xml,找到日期文本(如"2019/3")
|
||||||
|
- ⚠️ 日期经常被拆成多个run(如"201"+"9"+"/"+"3")
|
||||||
|
- 需逐run处理:拼出完整日期字符串 → 替换为当日(如"2026/7")
|
||||||
|
- 格式:YYYY/M(不补零)
|
||||||
|
|
||||||
|
### 3. 标题必须用合同正文全称
|
||||||
|
- 读合同docx正文P0(或前几段)获取合同标题全称
|
||||||
|
- 填入审查意见文档的《》内
|
||||||
|
- ❌ 不能用文件名(文件名可能是简称、带前缀、有(1)后缀)
|
||||||
|
- ✅ 必须是合同正文中出现的完整合同名称
|
||||||
|
|
||||||
|
## 内容规则(不变)
|
||||||
|
- 只写原文和修订后内容,不做理由说明
|
||||||
|
- 不写(注:...)
|
||||||
|
- 行顺序按条款号排列
|
||||||
|
- 有修改意见时删除"无法律修改意见。"段落
|
||||||
|
- 批注内容也要体现在表格中
|
||||||
|
|
||||||
|
## 字体硬规则
|
||||||
|
| 位置 | eastAsia | ascii | hAnsi | sz | bold | hint |
|
||||||
|
|------|----------|-------|-------|-----|------|------|
|
||||||
|
| 标题 | 仿宋 | - | - | 32(16pt) | True | eastAsia |
|
||||||
|
| 表头 | 仿宋 | TNR | TNR | 24(12pt) | True | eastAsia |
|
||||||
|
| 数据行 | 仿宋 | TNR | TNR | 24(12pt) | False | eastAsia |
|
||||||
|
| 签名 | 仿宋 | TNR | TNR | 24(12pt) | False | eastAsia |
|
||||||
|
|
||||||
|
## 同模板合同审查意见一致性
|
||||||
|
- 共有修订行内容完全一致
|
||||||
|
- 行顺序统一(按条款号)
|
||||||
|
- 个案差异行按各合同实际情况(如金额批注)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 审查意见文档格式规则 (2026-07-13 Doro纠正)
|
||||||
|
|
||||||
|
## 规则来源
|
||||||
|
朱家角 review-rules.md "审查意见格式要求" 章节 (2026-07-13 更新)
|
||||||
|
|
||||||
|
## 规则内容
|
||||||
|
|
||||||
|
### 1. 删除表格中的空白行
|
||||||
|
- "空白行"指的是审查意见表格中**三列全空**的行(条文/原文/修订后都没有内容)
|
||||||
|
- 不是正文段落的空白行——正文段落空行是排版问题,表格空行才是Doro说的"删除空白行"
|
||||||
|
- 2026-07-13教训:Doro说"删除空白行",小Maggie误解为删正文段落空行,被纠正后才看到是表格Row1-Row4全空
|
||||||
|
|
||||||
|
### 2. 页眉日期改为修订当日
|
||||||
|
- 审查意见模板页眉中有日期(如 header2.xml 中 "2019/3")
|
||||||
|
- 生成审查意见时必须更新为**修订当日**的年/月(如"2026/7")
|
||||||
|
- 注意:页眉中的日期可能拆分在多个run中(如"201"+"9"+"/"+"3"),需逐run定位修改
|
||||||
|
|
||||||
|
### 3. 标题必须用合同正文全称
|
||||||
|
- 规则:"标题《》内填写所审查的合同名称"
|
||||||
|
- "合同名称" = 合同正文第一段的标题(如"医疗设备器械购销合同"),**不是文件名**
|
||||||
|
- 文件名可能是简写(如"医疗合同(2).doc"),但审查意见标题必须写全称
|
||||||
|
- 2026-07-13教训:文件名"医疗合同(2)",合同正文标题是"医疗设备器械购销合同",审查意见标题应为"关于《医疗设备器械购销合同》的审查意见"
|
||||||
|
|
||||||
|
## Workflow重复处理检测(2026-07-13 香花桥安全生产合同教训)
|
||||||
|
|
||||||
|
### 问题
|
||||||
|
待审查目录的文件可能**已含WB tracked changes**(上一轮workflow产出被放回了待审查)。workflow不做去重检测,会在已有修订上再跑一遍,导致:
|
||||||
|
- 文字重复(如"全部损失全部损失")
|
||||||
|
- 相同内容被双重标记为INS(冗余修订痕迹)
|
||||||
|
|
||||||
|
### 检测方法
|
||||||
|
修复/审查前**第一步**:检查待审查文件是否已有author=WB的tracked changes
|
||||||
|
```python
|
||||||
|
for ins in body.iter(f'{WNS}ins'):
|
||||||
|
if ins.get(f'{WNS}author') == 'WB':
|
||||||
|
# 文件已被处理过!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 正确做法
|
||||||
|
如果待审查文件已有WB修订:
|
||||||
|
1. **以待审查版为基底**(它的第一轮修订是正确的)
|
||||||
|
2. 只在此基础上补充缺失的修订(如名称统一)
|
||||||
|
3. **不使用任务交付目录的二次处理版本**(它有重复)
|
||||||
|
4. 排查是否是auto_notify重复触发或手动误操作导致
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 审查意见文档生成模式(2026-07-02 确立)
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
1. **只体现差异,不做理由说明** — 表格三列(条文|原文|修订后)只写文字差异
|
||||||
|
2. **字体必须显式设置** — 不依赖模板继承,每个run四属性齐全
|
||||||
|
3. **同模板合同内容必须一致** — 行顺序按条款号,模板级修订表述相同
|
||||||
|
|
||||||
|
## 字体规则
|
||||||
|
|
||||||
|
```python
|
||||||
|
def set_cell_font(cell, text, east_asia='仿宋', ascii_font='Times New Roman', h_ansi='Times New Roman', bold=False):
|
||||||
|
"""Set cell text with proper font - every run must have explicit rFonts"""
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
from docx.oxml import OxmlElement
|
||||||
|
|
||||||
|
# Clear existing content
|
||||||
|
for p in cell.paragraphs[1:]:
|
||||||
|
cell._element.remove(p._element)
|
||||||
|
p = cell.paragraphs[0]
|
||||||
|
for r in p._element.findall(qn('w:r')):
|
||||||
|
p._element.remove(r)
|
||||||
|
|
||||||
|
# Set paragraph alignment to justify
|
||||||
|
pPr = p._element.find(qn('w:pPr'))
|
||||||
|
if pPr is None:
|
||||||
|
pPr = OxmlElement('w:pPr')
|
||||||
|
p._element.insert(0, pPr)
|
||||||
|
jc = pPr.find(qn('w:jc'))
|
||||||
|
if jc is None:
|
||||||
|
jc = OxmlElement('w:jc')
|
||||||
|
pPr.append(jc)
|
||||||
|
jc.set(qn('w:val'), 'both')
|
||||||
|
|
||||||
|
# Add run with explicit font settings
|
||||||
|
run = p.add_run(text)
|
||||||
|
rPr = run._element.find(qn('w:rPr'))
|
||||||
|
if rPr is None:
|
||||||
|
rPr = OxmlElement('w:rPr')
|
||||||
|
run._element.insert(0, rPr)
|
||||||
|
|
||||||
|
rFonts = OxmlElement('w:rFonts')
|
||||||
|
rFonts.set(qn('w:eastAsia'), east_asia)
|
||||||
|
rFonts.set(qn('w:ascii'), ascii_font)
|
||||||
|
rFonts.set(qn('w:hAnsi'), h_ansi)
|
||||||
|
rPr.insert(0, rFonts)
|
||||||
|
|
||||||
|
if bold:
|
||||||
|
b = OxmlElement('w:b')
|
||||||
|
rPr.append(b)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 内容格式
|
||||||
|
|
||||||
|
### ✅ 正确(只体现差异)
|
||||||
|
| 条文 | 原文 | 修订后 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 第1条 | 买方同意向卖方购买,同时卖方同意授予买方以下器械 | 甲方同意向乙方购买,同时乙方同意向甲方出售以下器械 |
|
||||||
|
| 第7.1.2条 | 按照器械的疵劣程度 | 按照器械的瑕疵程度 |
|
||||||
|
|
||||||
|
### ❌ 错误(带理由说明)
|
||||||
|
| 条文 | 原文 | 修订后 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 第1条 | ... | 甲方同意向乙方购买……(注:统一称谓为甲方/乙方,"授予"修改为"出售"以准确反映买卖关系) |
|
||||||
|
|
||||||
|
## 同模板合同一致性保证
|
||||||
|
|
||||||
|
当同一顾问单位有多份同模板合同时:
|
||||||
|
|
||||||
|
1. 先确定模板级修订点列表(所有同模板合同共享的问题)
|
||||||
|
2. 每份合同的审查意见必须包含**全部**模板级修订点
|
||||||
|
3. 行顺序统一按条款号排列
|
||||||
|
4. 个案差异(如金额问题)在统一行之外单独加行
|
||||||
|
5. 修订后列的文字必须完全一致(逐字对比)
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
生成完成后必须验证:
|
||||||
|
- [ ] 所有数据行的每个run都有eastAsia=仿宋 + ascii/hAnsi=Times New Roman
|
||||||
|
- [ ] 修订后列无(注:...)、无理由解释
|
||||||
|
- [ ] 行顺序按条款号排列
|
||||||
|
- [ ] 同模板合同的审查意见行数一致(除个案差异行外)
|
||||||
|
- [ ] 标题包含合同全称
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 审查意见文档生成规则(朱家角模板)
|
||||||
|
|
||||||
|
## 2026-07-02 Doro多次纠正后确立
|
||||||
|
|
||||||
|
### 模板结构
|
||||||
|
路径:`~/.hermes/shared/模版库/朱家角 审查意见【模板】.docx`
|
||||||
|
(新模板参考:`Doro合同审查任务/参考文件/朱家角 审查意见【新模板】.docx`)
|
||||||
|
|
||||||
|
1. 空行(P0, 居中)
|
||||||
|
2. 标题:`关于《XX》的审查意见`(居中、仿宋 **16pt**(sz=32) **加粗**)
|
||||||
|
3. `无法律修改意见。`(有审查意见时**删除此行**)
|
||||||
|
4. `审查意见:`(仿宋 12pt)
|
||||||
|
5. 表格(条文|原文|修订后)
|
||||||
|
6. 空行
|
||||||
|
7. 签名:`邱庭 律师`(右对齐、仿宋 12pt)
|
||||||
|
|
||||||
|
### 字体规格(从模板XML实际读取,非推测)
|
||||||
|
- 标题:eastAsia=仿宋, sz=32(16pt), bold=True, **ascii=None**(模板未设)
|
||||||
|
- 表头行:eastAsia=仿宋, sz=24(12pt), bold=True
|
||||||
|
- 数据行:eastAsia=仿宋, ascii=Times New Roman, hAnsi=Times New Roman, sz=24(12pt), bold=False, hint=eastAsia
|
||||||
|
- 正文段:eastAsia=仿宋, sz=24(12pt)
|
||||||
|
|
||||||
|
⚠️ 模板本身只设了`eastAsia=仿宋`没有设`ascii`。按review-rules.md要求英文用TNR,生成时应显式设ascii/hAnsi=Times New Roman。
|
||||||
|
|
||||||
|
### 文档格式处理(2026-07-12 Doro要求)
|
||||||
|
- **删除空白行**:文档中所有无内容的空段落必须删除(模板自带的空行也删)
|
||||||
|
- **页眉日期改为修订当日**:header*.xml中如有日期(如"2019/3"),改为当日日期(如"2026/7")。注意日期可能被拆分为多个run(如"201"+"9"+"/"+"3"),需逐run处理
|
||||||
|
- **标题用合同正文中的实际标题**:从合同docx正文提取合同全称(如"医疗设备器械购销合同"),填入《》内。绝不用文件名代替(文件名可能是简称如"医疗合同(2)")
|
||||||
|
- **标题格式**:`关于《XX合同全称》的审查意见`,确保无多余占位符残留
|
||||||
|
|
||||||
|
### 内容规则(2026-07-02 Doro明确)
|
||||||
|
- **只写原文和修订后的内容(包括批注内容),不做理由说明**
|
||||||
|
- ❌ 不写 (注:统一称谓…)
|
||||||
|
- ❌ 不写 (注:原引用法规…)
|
||||||
|
- ✅ 条文列:第X条 / 第X.X条 / 新增X.X条(主题)
|
||||||
|
- ✅ 原文列:合同原文
|
||||||
|
- ✅ 修订后列:修订后文字 / 批注内容(如"请注意确认金额")
|
||||||
|
- 行顺序按条款号排列
|
||||||
|
- 新增条款:条文栏写"新增X.X条(主题)",修订后栏直接写条文内容
|
||||||
|
|
||||||
|
### 同模板合同的审查意见统一规则
|
||||||
|
- 格式、字体、行顺序统一
|
||||||
|
- 共有修订行内容完全相同
|
||||||
|
- 个案差异行(如金额批注)按各合同实际情况处理
|
||||||
|
- 没有问题的合同不要强加批注行
|
||||||
|
|
||||||
|
### 生成代码要点
|
||||||
|
```python
|
||||||
|
# 字体设置(数据行)
|
||||||
|
def set_run_font(run_elem, east_asia='仿宋', ascii_font='Times New Roman', h_ansi='Times New Roman', sz_val=24):
|
||||||
|
rFonts.set(qn('w:eastAsia'), east_asia)
|
||||||
|
rFonts.set(qn('w:ascii'), ascii_font)
|
||||||
|
rFonts.set(qn('w:hAnsi'), h_ansi)
|
||||||
|
rFonts.set(qn('w:hint'), 'eastAsia')
|
||||||
|
sz.set(qn('w:val'), str(sz_val)) # 24 = 12pt
|
||||||
|
szCs.set(qn('w:val'), str(sz_val))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常见错误(本session犯过的)
|
||||||
|
1. 未设sz_val → 回退到默认字号
|
||||||
|
2. ascii设成仿宋 → 英文也变仿宋
|
||||||
|
3. 用bytes literal写中文 → unicode转义不解析显示乱码
|
||||||
|
4. 忘删"无法律修改意见。" → 矛盾
|
||||||
|
5. 写(注:...)理由 → 规则禁止
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 同模板合同审查一致性规则
|
||||||
|
|
||||||
|
## 2026-07-02 朱家角恭兴+肃言合同教训
|
||||||
|
|
||||||
|
### 问题
|
||||||
|
两份同模板购销合同(结构完全一致,仅乙方名称和设备清单不同),workflow串行审查后修订不一致:
|
||||||
|
- 恭兴发现了6.4条(药监局法规过时)但遗漏7.3条(侵权兜底)
|
||||||
|
- 肃言发现了7.3条但遗漏6.4条
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
workflow是逐份串行处理(relay-runner),每份合同完全独立走reviewer→editor→deliverer,各session之间零状态共享。LLM每次独立推理,对同一段文字的优先级判断有随机性。
|
||||||
|
|
||||||
|
### 解决办法(已实施)
|
||||||
|
|
||||||
|
1. **review-rules.md增加同模板一致性规则**(已做)
|
||||||
|
2. **reviewer skill增加前置检查**:审查前检查同目录是否有同模板已审查的合同,对齐修订点
|
||||||
|
3. **手动审查时的铁律**:同批同模板合同必须先审完一份→确认修订点→后续合同按同样标准执行
|
||||||
|
|
||||||
|
### 判断"同模板"的方法
|
||||||
|
- 合同标题完全相同
|
||||||
|
- 正文条款结构一致(前20行匹配度>80%)
|
||||||
|
- 甲方相同,乙方不同
|
||||||
|
- 区别仅在商业条款(金额、设备清单、乙方信息等)
|
||||||
|
|
||||||
|
### 审查意见的统一要求
|
||||||
|
- 共有修订行:内容必须完全一致
|
||||||
|
- 行顺序:按条款号排列
|
||||||
|
- 个案差异:只有真实存在的问题才加行(如恭兴金额有误加批注行,肃言金额正确则不加)
|
||||||
|
- 格式:字体/字号/对齐统一
|
||||||
|
|
||||||
|
### 批注的统一原则
|
||||||
|
"统一"是审查逻辑统一,不是机械复制。金额有问题的合同加批注,没问题的不加——逻辑一致即可。不能给正确的合同强加问题批注。
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Same-Template Revision Transfer (同模板修订参照)
|
||||||
|
|
||||||
|
When Doro says "参照X合同的修订进行修订" — apply the same WB revisions from a reference contract to another contract using the same template.
|
||||||
|
|
||||||
|
## ⚠️ Doro 强制验证纪律(2026-07-08 明确要求)
|
||||||
|
|
||||||
|
Doro 明确要求做同模板修订参照时必须走完以下步骤,缺一不可:
|
||||||
|
|
||||||
|
1. **先确认模板一致性**:逐段对比两份合同原文(去掉修订后的文本),确认段落数一致、差异仅限业务内容(项目名称、单价等),其余结构完全相同。打印差异段数/总段数(如"9/62段有差异")。
|
||||||
|
2. **参照修订**:提取参照合同的WB修订→适配目标合同业务语境→应用
|
||||||
|
3. **格式/字体/编号全检**:所有INS的rPr必须与前后邻居run一致(逐个检查sz/rFonts/bold)。遵守workflow规则(author=WB、精准到字、不整段del+ins)
|
||||||
|
4. **全文阅读审查合理性**:渲染accept后全文,逐段通读确认修订逻辑合理、不破坏上下文语义
|
||||||
|
5. **交付前检查**:python-docx可打开、无异常字符、修订数与参照合同一致、他人修订保持不动
|
||||||
|
|
||||||
|
**不能跳步直接做修订然后上传。** Doro原话:"你先确认:两个合同是不是模板一样,内容一样;如果一样,参照修改;你修改的格式、字体、编号等,都要遵守workflow的规则;全文阅读,修订是否合理。最后检查交付。"
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1: Extract revisions from reference contract
|
||||||
|
|
||||||
|
```python
|
||||||
|
import zipfile
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||||
|
|
||||||
|
def extract_wb_revisions(filepath):
|
||||||
|
"""Extract all WB-authored INS and DEL from a contract."""
|
||||||
|
with zipfile.ZipFile(filepath, 'r') as z:
|
||||||
|
content = z.read('word/document.xml')
|
||||||
|
tree = etree.fromstring(content)
|
||||||
|
|
||||||
|
revisions = []
|
||||||
|
for ins in tree.iter(f'{{{ns}}}ins'):
|
||||||
|
if ins.get(f'{{{ns}}}author') != 'WB':
|
||||||
|
continue
|
||||||
|
texts = [t.text for t in ins.iter(f'{{{ns}}}t') if t.text]
|
||||||
|
# Get parent paragraph for context
|
||||||
|
parent_p = ins
|
||||||
|
while parent_p is not None and parent_p.tag != f'{{{ns}}}p':
|
||||||
|
parent_p = parent_p.getparent()
|
||||||
|
p_texts = [t.text for t in parent_p.iter(f'{{{ns}}}t') if t.text] if parent_p is not None else []
|
||||||
|
revisions.append({
|
||||||
|
'type': 'INS', 'text': ''.join(texts),
|
||||||
|
'para_context': ''.join(p_texts)[:120]
|
||||||
|
})
|
||||||
|
|
||||||
|
for d in tree.iter(f'{{{ns}}}del'):
|
||||||
|
if d.get(f'{{{ns}}}author') != 'WB':
|
||||||
|
continue
|
||||||
|
texts = [t.text for t in d.iter(f'{{{ns}}}delText') if t.text]
|
||||||
|
parent_p = d
|
||||||
|
while parent_p is not None and parent_p.tag != f'{{{ns}}}p':
|
||||||
|
parent_p = parent_p.getparent()
|
||||||
|
p_texts = [t.text for t in parent_p.iter(f'{{{ns}}}t') if t.text] if parent_p is not None else []
|
||||||
|
revisions.append({
|
||||||
|
'type': 'DEL', 'text': ''.join(texts),
|
||||||
|
'para_context': ''.join(p_texts)[:120]
|
||||||
|
})
|
||||||
|
return revisions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Identify modification patterns
|
||||||
|
|
||||||
|
Group INS/DEL pairs by paragraph context to understand what was changed:
|
||||||
|
- Simple text replacement: DEL "协议" + INS "合同" in same paragraph
|
||||||
|
- Text insertion: INS without corresponding DEL (e.g., data ownership sentence)
|
||||||
|
- Prefix insertion: INS "上海市" before existing text
|
||||||
|
|
||||||
|
### Step 3: Context adaptation
|
||||||
|
|
||||||
|
When the template is shared but service content differs, adapt context-specific terms:
|
||||||
|
- "体检服务" → "口腔检查服务"
|
||||||
|
- "学生个人信息、健康检查结果" → "个人信息、检查结果"
|
||||||
|
- Keep legal boilerplate identical (e.g., "归甲方所有", "合同期满")
|
||||||
|
|
||||||
|
### Step 4: Apply to target contract (zipfile+lxml)
|
||||||
|
|
||||||
|
Use three operations:
|
||||||
|
|
||||||
|
#### A. Tracked replace (DEL old + INS new)
|
||||||
|
```python
|
||||||
|
def do_tracked_replace(para, find_text, replace_text):
|
||||||
|
"""Find text in paragraph runs, create DEL + INS."""
|
||||||
|
# Build character map from runs (skip ins/del elements)
|
||||||
|
char_map = []
|
||||||
|
for elem in para:
|
||||||
|
if elem.tag == f'{{{ns}}}r':
|
||||||
|
t = elem.find(f'{{{ns}}}t')
|
||||||
|
if t is not None and t.text:
|
||||||
|
for ci in range(len(t.text)):
|
||||||
|
char_map.append((elem, t, ci))
|
||||||
|
|
||||||
|
full_text = ''.join(cm[1].text[cm[2]] for cm in char_map)
|
||||||
|
pos = full_text.find(find_text)
|
||||||
|
if pos == -1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Verify single-run containment, then split into before/DEL/INS/after
|
||||||
|
# ... (see session code for full implementation)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. Insert before text
|
||||||
|
```python
|
||||||
|
def do_tracked_insert_before(para, anchor_text, insert_text):
|
||||||
|
"""Insert INS element right before anchor_text."""
|
||||||
|
for elem in para:
|
||||||
|
if elem.tag == f'{{{ns}}}r':
|
||||||
|
t = elem.find(f'{{{ns}}}t')
|
||||||
|
if t is not None and t.text and anchor_text in t.text:
|
||||||
|
# Split run, insert INS before anchor portion
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### C. Insert after text
|
||||||
|
```python
|
||||||
|
def do_tracked_insert_after(para, anchor_text, insert_text):
|
||||||
|
"""Insert INS element right after anchor_text."""
|
||||||
|
for elem in para:
|
||||||
|
if elem.tag == f'{{{ns}}}r':
|
||||||
|
t = elem.find(f'{{{ns}}}t')
|
||||||
|
if t is not None and t.text and anchor_text in t.text:
|
||||||
|
# Split run, insert INS after anchor portion
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Attribution Rule (2026-07-08 Doro clarification)
|
||||||
|
|
||||||
|
When writing data ownership/attribution clauses across same-template contracts:
|
||||||
|
|
||||||
|
**LOCKED (identical across all contracts)**: 归属表述 = "归甲方或相关权利方所有"
|
||||||
|
- NOT "归甲方所有" (excludes data subjects' rights under PIPL)
|
||||||
|
- The phrase "甲方或相关权利方" covers both: data甲方 owns (aggregated stats, service outputs) AND personal info that belongs to data subjects
|
||||||
|
|
||||||
|
**NOT LOCKED (varies by contract)**: The descriptive content before the attribution phrase
|
||||||
|
- 体检合同: "乙方在提供体检服务过程中获取和产生的全部数据(包括但不限于学生个人信息、健康检查结果等)"
|
||||||
|
- 口腔检查合同: "乙方在提供口腔检查服务过程中获取和产生的全部数据(包括但不限于个人信息、检查结果等)"
|
||||||
|
- Other contracts: adapt to the specific service/data context
|
||||||
|
|
||||||
|
**Doro原话**: "我只需要涉及到数据权利的归属时,把归属谁改成'归甲方或相关权利方所有',其他的内容不同合同会不同,所以你不能写死。"
|
||||||
|
|
||||||
|
**Rule source**: `review-rules.md` §4 保密/数据 (updated 2026-07-08)
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
|
||||||
|
### 1. `<w:proofErr>` splits runs
|
||||||
|
Text like "青浦区练塘镇" may be split into multiple runs separated by `<w:proofErr>` elements:
|
||||||
|
```xml
|
||||||
|
<w:r><w:t>青浦区练塘</w:t></w:r>
|
||||||
|
<w:proofErr w:type="gramStart"/>
|
||||||
|
<w:r><w:t>镇社区卫生服务中心</w:t></w:r>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix**: Search at individual run level (`for elem in para: if elem.tag == w:r`), not at full paragraph text level. Insert INS before the run containing the anchor, not at a text position within full paragraph text.
|
||||||
|
|
||||||
|
### 2. "达成如下协议" — not all "协议" should be replaced
|
||||||
|
In the reference contract, "达成如下协议:" was NOT changed (it's a formulaic expression meaning "reached the following agreement"). Only contextual uses of "协议" meaning "this agreement/contract" were changed to "合同"/"本合同".
|
||||||
|
|
||||||
|
**Rule**: Compare reference contract's accepted text to determine which instances were changed and which were left alone.
|
||||||
|
|
||||||
|
### 3. INS rPr must clone from target run (not reference)
|
||||||
|
The target contract's runs may have different formatting than the reference. Always clone rPr from the **target paragraph's existing run**, not from the reference contract.
|
||||||
|
|
||||||
|
### 4. Order of operations matters
|
||||||
|
Do replacements BEFORE insertions. Insertions change paragraph structure and character positions, which can break subsequent text searches.
|
||||||
|
|
||||||
|
Recommended order:
|
||||||
|
1. All `do_tracked_replace` calls (these only split existing runs)
|
||||||
|
2. All `do_tracked_insert_after` / `do_tracked_insert_before` calls (these add new elements)
|
||||||
|
|
||||||
|
### 5. Verify with accept-all view
|
||||||
|
After applying all revisions, verify by building accepted text (skip DEL, include INS) for key paragraphs and comparing against the reference contract's accepted text.
|
||||||
|
|
||||||
|
## 2026-07-08 实证:练塘口腔检查合同
|
||||||
|
|
||||||
|
Reference: 【修】2026年学生体检外包合同--练塘(1).docx
|
||||||
|
Target: 2026年口腔检查外包合同--练塘.docx
|
||||||
|
|
||||||
|
Modifications applied:
|
||||||
|
| # | Type | Content | Adaptation |
|
||||||
|
|---|------|---------|------------|
|
||||||
|
| 1 | INS before "青浦区" | "上海市" | None (identical) |
|
||||||
|
| 2a | INS after "保密义务。" | Data ownership sentence | "体检服务"→"口腔检查服务", "学生个人信息、健康检查结果"→"个人信息、检查结果" |
|
||||||
|
| 2b | DEL "协议" + INS "合同" | "协议期满"→"合同期满" | None |
|
||||||
|
| 2c | DEL "服务协议" + INS "本合同" | "服务协议解除"→"本合同解除" | None |
|
||||||
|
| 2d | DEL "本协议" + INS "本合同" | "本协议的履行"→"本合同的履行" | None |
|
||||||
|
| 3 | DEL "本协议" + INS "本合同" | "本协议一式"→"本合同一式" | None |
|
||||||
|
|
||||||
|
proofErr pitfall encountered: "青浦区练塘" split by `<w:proofErr>` — had to insert at run level rather than text-position level.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 单段正文章节不加numPr(2026-07-12 盈浦健康科普合同)
|
||||||
|
|
||||||
|
## 规则
|
||||||
|
当新增的章节(如"八、转包与分包")下只有**一段**正文时,该段落**不设numPr**。
|
||||||
|
|
||||||
|
## 判断方法
|
||||||
|
1. 看原文中同样只有一段正文的章节是否有numPr
|
||||||
|
2. 如果原文单段章节无numPr(如"一、合作背景"P12无numPr),新增也不加
|
||||||
|
3. 多段正文章节有numPr(如"七、不可抗力"两段都有numId=11)
|
||||||
|
4. 这是"有2才有1"规则在numPr层面的体现
|
||||||
|
|
||||||
|
## 实证
|
||||||
|
盈浦健康科普服务合同:
|
||||||
|
- 原文"一、合作背景": 1段正文 → 无numPr
|
||||||
|
- 原文"七、不可抗力": 2段正文 → numId=11
|
||||||
|
- 新增"八、转包与分包": 1段正文 → 不应有numPr
|
||||||
|
|
||||||
|
错误地加了numId=14(新建abstractNum),导致OnlyOffice渲染出孤零零的"1."
|
||||||
|
|
||||||
|
## 同时要检查的段落格式
|
||||||
|
新增段落的pPr必须与原文同类型段落**完整匹配**:
|
||||||
|
- `w:ind`(firstLine/firstLineChars)—— 首行缩进
|
||||||
|
- `w:spacing`(line/lineRule)
|
||||||
|
- 不能只有spacing没有ind
|
||||||
|
|
||||||
|
实证:原文正文段有 `ind firstLine=420 firstLineChars=200`,workflow新增P75只有spacing缺ind → 渲染无首行缩进。
|
||||||
|
|
||||||
|
## heading run的sz继承陷阱
|
||||||
|
原文Heading 1样式定义sz=48(24pt),heading段落的plain run**没有显式sz**(靠样式继承)。
|
||||||
|
workflow/ContractEditor操作后可能给某个run添加spurious `sz=20`(从szCs误取),导致该run从24pt变成10pt。
|
||||||
|
|
||||||
|
检查:修订后Heading段落的所有plain run不应有新增的显式sz。
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# 拆分合并的标题+正文段落为两个独立INS段落
|
||||||
|
|
||||||
|
## 场景
|
||||||
|
|
||||||
|
Reviewer发现新增条款的标题和正文被合并在一个`<w:p>`段落中(通过`<w:t>`内的换行符分隔),要求拆分为两个独立段落——标题段和正文段,各有独立的格式。
|
||||||
|
|
||||||
|
## 判别
|
||||||
|
|
||||||
|
- 目标段落是一个`<w:p>`,内含一个`<w:ins author="WB">`,`<w:ins>`内只有一个`<w:r>`,`<w:t>`文本包含换行符(`\n`)分隔标题和正文
|
||||||
|
- 标题格式要求:参照原文同级标题段落(如"第四条"或"第六条")
|
||||||
|
- 正文格式要求:参照原文同层级正文段落(如"一、施工期限")
|
||||||
|
|
||||||
|
## 操作步骤
|
||||||
|
|
||||||
|
### 1. 读取原文并定位目标段落
|
||||||
|
|
||||||
|
```python
|
||||||
|
import zipfile
|
||||||
|
from lxml import etree
|
||||||
|
import copy
|
||||||
|
|
||||||
|
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||||
|
|
||||||
|
with zipfile.ZipFile(docx_path, 'r') as zf:
|
||||||
|
doc_xml = etree.parse(zf.open('word/document.xml'))
|
||||||
|
all_files = {name: zf.read(name) for name in zf.namelist()}
|
||||||
|
|
||||||
|
body = doc_xml.getroot().find(f'{{{W}}}body')
|
||||||
|
paragraphs = list(body.findall(f'{{{W}}}p'))
|
||||||
|
|
||||||
|
# 找到目标段落
|
||||||
|
for i, p in enumerate(paragraphs):
|
||||||
|
texts = []
|
||||||
|
for elem in p.iter(f'{{{W}}}t'):
|
||||||
|
texts.append(elem.text or '')
|
||||||
|
full_text = ''.join(texts)
|
||||||
|
if '第五条' in full_text and '转包' in full_text:
|
||||||
|
target_idx = i
|
||||||
|
break
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 提取标题和正文文本
|
||||||
|
|
||||||
|
```python
|
||||||
|
full_text = ''
|
||||||
|
for elem in target_p.iter(f'{{{W}}}t'):
|
||||||
|
full_text += elem.text or ''
|
||||||
|
|
||||||
|
lines = full_text.split('\n')
|
||||||
|
title_text = lines[0].strip() # "第五条 转包与分包"
|
||||||
|
body_text = '\n'.join(lines[1:]).strip() # 正文内容
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 找到参照段落并克隆pPr
|
||||||
|
|
||||||
|
标题段pPr从紧邻的同级标题段落克隆(如"第六条"),正文段pPr从同层级正文段落克隆(如"一、施工期限")。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 标题参照段落(如"第六条")
|
||||||
|
ref_title_p = paragraphs[24] # 原文"第六条"的索引
|
||||||
|
ref_title_pPr = ref_title_p.find(f'{{{W}}}pPr')
|
||||||
|
title_pPr = copy.deepcopy(ref_title_pPr)
|
||||||
|
|
||||||
|
# 正文参照段落(如"一、施工期限")
|
||||||
|
ref_body_p = paragraphs[13] # 原文"一、施工期限"的索引
|
||||||
|
ref_body_pPr = ref_body_p.find(f'{{{W}}}pPr')
|
||||||
|
body_pPr = copy.deepcopy(ref_body_pPr)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 构建标题段落
|
||||||
|
|
||||||
|
```python
|
||||||
|
title_p = etree.Element(f'{{{W}}}p', nsmap=target_p.nsmap)
|
||||||
|
title_p.append(title_pPr)
|
||||||
|
|
||||||
|
# 标题rPr:黑体四属性 + hint=eastAsia + sz=24 + bold
|
||||||
|
title_rPr = etree.Element(f'{{{W}}}rPr')
|
||||||
|
rFonts = etree.SubElement(title_rPr, f'{{{W}}}rFonts')
|
||||||
|
for attr in ['ascii', 'hAnsi', 'eastAsia', 'cs']:
|
||||||
|
rFonts.set(f'{{{W}}}{attr}', '黑体')
|
||||||
|
rFonts.set(f'{{{W}}}hint', 'eastAsia')
|
||||||
|
etree.SubElement(title_rPr, f'{{{W}}}spacing').set(f'{{{W}}}val', '-6')
|
||||||
|
etree.SubElement(title_rPr, f'{{{W}}}sz').set(f'{{{W}}}val', '24')
|
||||||
|
etree.SubElement(title_rPr, f'{{{W}}}szCs').set(f'{{{W}}}val', '24')
|
||||||
|
etree.SubElement(title_rPr, f'{{{W}}}b') # 加粗
|
||||||
|
|
||||||
|
title_ins = etree.SubElement(title_p, f'{{{W}}}ins')
|
||||||
|
title_ins.set(f'{{{W}}}id', str(new_ins_id))
|
||||||
|
title_ins.set(f'{{{W}}}author', 'WB')
|
||||||
|
title_ins.set(f'{{{W}}}date', '2026-06-26T14:00:00Z')
|
||||||
|
|
||||||
|
title_r = etree.SubElement(title_ins, f'{{{W}}}r')
|
||||||
|
title_r.set(f'{{{W}}}rsidR', '00AA0001')
|
||||||
|
title_r.append(copy.deepcopy(title_rPr))
|
||||||
|
title_t = etree.SubElement(title_r, f'{{{W}}}t')
|
||||||
|
title_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||||
|
title_t.text = title_text
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 构建正文段落
|
||||||
|
|
||||||
|
```python
|
||||||
|
body_p = etree.Element(f'{{{W}}}p', nsmap=target_p.nsmap)
|
||||||
|
body_p.append(body_pPr)
|
||||||
|
|
||||||
|
# 正文rPr:宋体四属性 + hint=eastAsia + sz=21
|
||||||
|
body_rPr = etree.Element(f'{{{W}}}rPr')
|
||||||
|
body_rFonts = etree.SubElement(body_rPr, f'{{{W}}}rFonts')
|
||||||
|
for attr in ['ascii', 'hAnsi', 'eastAsia', 'cs']:
|
||||||
|
body_rFonts.set(f'{{{W}}}{attr}', '宋体')
|
||||||
|
body_rFonts.set(f'{{{W}}}hint', 'eastAsia')
|
||||||
|
etree.SubElement(body_rPr, f'{{{W}}}spacing').set(f'{{{W}}}val', '-4')
|
||||||
|
etree.SubElement(body_rPr, f'{{{W}}}sz').set(f'{{{W}}}val', '21')
|
||||||
|
|
||||||
|
body_ins = etree.SubElement(body_p, f'{{{W}}}ins')
|
||||||
|
body_ins.set(f'{{{W}}}id', str(new_ins_id + 1))
|
||||||
|
body_ins.set(f'{{{W}}}author', 'WB')
|
||||||
|
body_ins.set(f'{{{W}}}date', '2026-06-26T14:00:00Z')
|
||||||
|
|
||||||
|
body_r = etree.SubElement(body_ins, f'{{{W}}}r')
|
||||||
|
body_r.set(f'{{{W}}}rsidR', '00AA0001')
|
||||||
|
body_r.append(copy.deepcopy(body_rPr))
|
||||||
|
body_t = etree.SubElement(body_r, f'{{{W}}}t')
|
||||||
|
body_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||||
|
body_t.text = body_text
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 插入并删除原段落(⚠️ addprevious顺序陷阱)
|
||||||
|
|
||||||
|
**关键**:`addprevious`将元素插入到目标元素的**紧邻前一个**位置。要得到 [title, body, old_p] 的顺序,必须:
|
||||||
|
|
||||||
|
```python
|
||||||
|
old_p = paragraphs[target_idx]
|
||||||
|
old_p.addprevious(body_p) # 先插入body → 顺序: body, old_p
|
||||||
|
body_p.addprevious(title_p) # 再在body前插入title → 顺序: title, body, old_p
|
||||||
|
body.remove(old_p) # 删除原段落 → 顺序: title, body, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误做法**(会导致顺序反转):
|
||||||
|
```python
|
||||||
|
# ❌ 错误:title在body之后
|
||||||
|
old_p.addprevious(title_p) # title, old_p
|
||||||
|
old_p.addprevious(body_p) # title, body, old_p ← 看起来对但实际是 body, title, old_p
|
||||||
|
```
|
||||||
|
|
||||||
|
原理:`addprevious`始终插入到目标元素的紧邻前一个位置。`old_p.addprevious(body_p)` 后 body_p 是 old_p 的前一个兄弟;`old_p.addprevious(title_p)` 后 title_p 成为 old_p 的前一个兄弟,body_p 被推到 title_p 之前。
|
||||||
|
|
||||||
|
### 7. 保存
|
||||||
|
|
||||||
|
```python
|
||||||
|
new_doc_xml = etree.tostring(doc_xml.getroot(), xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(docx_path, 'w', zipfile.ZIP_DEFLATED) as zf_out:
|
||||||
|
for name, data in all_files.items():
|
||||||
|
if name == 'word/document.xml':
|
||||||
|
zf_out.writestr(name, new_doc_xml)
|
||||||
|
else:
|
||||||
|
zf_out.writestr(name, data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
1. **段落顺序**:确认 [title_idx] 是标题文本,[title_idx+1] 是正文文本
|
||||||
|
2. **字体属性**:标题 rPr 含 rFonts四属性(黑体) + hint=eastAsia + sz=24 + bold;正文 rPr 含 rFonts四属性(宋体) + hint=eastAsia + sz=21
|
||||||
|
3. **INS属性**:author=WB, 有 rsidR, 有唯一id
|
||||||
|
4. **OnlyOffice渲染**:x2t渲染为PDF,pdftotext确认标题和正文各占一行,正文有缩进
|
||||||
|
5. **validate()**:运行ContractEditor的validate(),区分预存误报和本轮新增问题
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 标题和正文的pPr应从**紧邻的原文同级段落**克隆,而非从目标段落自身克隆
|
||||||
|
- rFonts必须设置四属性(ascii, hAnsi, eastAsia, cs),仅设hint=eastAsia是不够的
|
||||||
|
- 标题的bold属性按照reviewer的指令设置(注意:原文标题可能不加粗,但reviewer可能要求加粗)
|
||||||
|
- 两个INS段落使用不同的id(从文档中max_ins_id+1开始递增)
|
||||||
|
- 操作前先备份原文件
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Split-Run Numbering in docx XML
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Contract numbering like `(5)` is often split across multiple `<w:r>` runs in the XML:
|
||||||
|
```xml
|
||||||
|
<w:r><w:t>(</w:t></w:r>
|
||||||
|
<w:r><w:t>5</w:t></w:r>
|
||||||
|
<w:r><w:t>)委托方</w:t></w:r>
|
||||||
|
```
|
||||||
|
|
||||||
|
A naive `tracked_replace("(5)", "(6)")` searching for the complete string in a single `<w:t>` will **silently fail** — no match, no error, no renumbering.
|
||||||
|
|
||||||
|
## Solution: Multi-run concatenation + split
|
||||||
|
|
||||||
|
### Algorithm
|
||||||
|
```python
|
||||||
|
def tracked_replace_split_number(p, old_num, new_num):
|
||||||
|
"""Handle (old_num) spread across multiple runs."""
|
||||||
|
target = f'({old_num})'
|
||||||
|
new_target = f'({new_num})'
|
||||||
|
|
||||||
|
# 1. Collect all plain runs (not inside w:ins or w:del)
|
||||||
|
plain_runs = [(index, run, text) for each child of p]
|
||||||
|
|
||||||
|
# 2. Slide a window: concatenate adjacent run texts until target is found
|
||||||
|
for start in range(len(plain_runs)):
|
||||||
|
concat = ""
|
||||||
|
for end in range(start, start+4): # max 4 runs for a number
|
||||||
|
concat += plain_runs[end].text
|
||||||
|
if target in concat:
|
||||||
|
# Found! Extract before/after text around the number
|
||||||
|
runs_to_wrap = plain_runs[start:end+1]
|
||||||
|
# ...proceed to replace
|
||||||
|
|
||||||
|
# 3. Remove original runs, insert:
|
||||||
|
# - [before_run if text before number]
|
||||||
|
# - DEL element with delText=target
|
||||||
|
# - INS element with t=new_target
|
||||||
|
# - [after_run if text after number, e.g. "委托方"]
|
||||||
|
|
||||||
|
# 4. Set rsid attributes: rsidDel on DEL runs, rsidR on INS runs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Critical: Process order
|
||||||
|
**Always renumber from bottom to top** (last paragraph first) to avoid index shifting:
|
||||||
|
```python
|
||||||
|
# CORRECT
|
||||||
|
renumber = [(P113, '10', '11'), (P112, '9', '10'), (P111, '7', '8')]
|
||||||
|
|
||||||
|
# WRONG - P112 was already renumbered when we get to it
|
||||||
|
renumber = [(P111, '7', '8'), (P112, '9', '10'), (P113, '10', '11')]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edge cases encountered (2026-06-08)
|
||||||
|
- `(` + `10)` (two runs, not three) — the closing `)` merged with the digit
|
||||||
|
- `(` + `5` + `)委托方` — closing `)` merged with following text, must split run to preserve "委托方"
|
||||||
|
- Copy `w:rPr` from original runs to all new DEL/INS runs to preserve font/size
|
||||||
|
|
||||||
|
## Lesson
|
||||||
|
This was the root cause of a terminal review failure where 3 new clauses were inserted without numbering, and subsequent numbering was not renumbered. The `tracked_replace` function matched nothing because it expected `(5)` as a single text node.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Standalone Char-Level Tracked Changes + Comments (non-workflow)
|
||||||
|
|
||||||
|
When modifying contracts **outside** the Doro/邱律师 workflow (e.g. Maggie directly asks to revise a client's agreement), the full `ContractEditor` library + review-rules machinery is overkill. Use this lightweight pattern instead.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
- Maggie sends a contract and says "帮我改一下" / "修改这份协议"
|
||||||
|
- No workflow, no reviewer, no deliverer — just direct revision
|
||||||
|
- Still must produce Word-native tracked changes (del/ins) + comments
|
||||||
|
|
||||||
|
## Core technique: `difflib.SequenceMatcher` char-level diff
|
||||||
|
|
||||||
|
```python
|
||||||
|
import difflib
|
||||||
|
from docx.oxml.ns import qn
|
||||||
|
from docx.oxml import OxmlElement
|
||||||
|
|
||||||
|
def char_level_replace(para, new_text, author="WB", date="2026-07-07T10:00:00Z"):
|
||||||
|
"""Replace paragraph text with char-level tracked changes.
|
||||||
|
Unchanged chars → normal w:r (preserved).
|
||||||
|
Deleted chars → w:del + w:delText.
|
||||||
|
Inserted chars → w:ins + w:t.
|
||||||
|
"""
|
||||||
|
p = para._element
|
||||||
|
old_text = para.text
|
||||||
|
if old_text == new_text:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove existing runs (preserve pPr)
|
||||||
|
for child in list(p):
|
||||||
|
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||||
|
if tag in ('r', 'ins', 'del', 'hyperlink'):
|
||||||
|
p.remove(child)
|
||||||
|
|
||||||
|
sm = difflib.SequenceMatcher(None, old_text, new_text)
|
||||||
|
for op, i1, i2, j1, j2 in sm.get_opcodes():
|
||||||
|
if op == 'equal':
|
||||||
|
p.append(make_run(old_text[i1:i2]))
|
||||||
|
elif op == 'delete':
|
||||||
|
p.append(make_del_run(old_text[i1:i2], author, date))
|
||||||
|
elif op == 'insert':
|
||||||
|
p.append(make_ins_run(new_text[j1:j2], author, date))
|
||||||
|
elif op == 'replace':
|
||||||
|
p.append(make_del_run(old_text[i1:i2], author, date))
|
||||||
|
p.append(make_ins_run(new_text[j1:j2], author, date))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comments injection (bypassing python-docx limitations)
|
||||||
|
|
||||||
|
python-docx has no native comment support. Inject manually:
|
||||||
|
|
||||||
|
1. Add `commentRangeStart` + `commentRangeEnd` + `commentReference` run to target paragraph
|
||||||
|
2. Build `word/comments.xml` as a plain string (proper namespace, no lxml serialization quirks)
|
||||||
|
3. Inject into the docx ZIP: update `[Content_Types].xml` + `word/_rels/document.xml.rels`
|
||||||
|
|
||||||
|
### Critical: comments.xml namespace
|
||||||
|
|
||||||
|
**Wrong** (causes "reuse of xmlns" error):
|
||||||
|
```python
|
||||||
|
comments_xml = etree.Element(qn('w:comments'))
|
||||||
|
comments_xml.set(qn('xmlns:w'), WNS) # ❌ double declaration
|
||||||
|
```
|
||||||
|
|
||||||
|
**Right** (build as plain string):
|
||||||
|
```python
|
||||||
|
def build_comments_xml(comments_list):
|
||||||
|
lines = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>']
|
||||||
|
lines.append('<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
|
||||||
|
' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">')
|
||||||
|
for cid, text in comments_list:
|
||||||
|
safe = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
lines.append(f' <w:comment w:id="{cid}" w:author="WB" w:date="..." w:initials="WB">')
|
||||||
|
lines.append(f' <w:p><w:r><w:t>{safe}</w:t></w:r></w:p>')
|
||||||
|
lines.append(f' </w:comment>')
|
||||||
|
lines.append('</w:comments>')
|
||||||
|
return "\n".join(lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pitfalls learned (2026-07-07 退休返聘案)
|
||||||
|
|
||||||
|
1. **lxml etree serialization breaks Word**: `etree.tostring()` produces `xmlns:ns0=...` prefix notation that Word/OnlyOffice cannot parse. Always build comments.xml as a plain string.
|
||||||
|
2. **Entire-paragraph del+ins is unacceptable**: Maggie and Doro both require char-level precision. "原文相同的部分保留,不一样的用修订" — this is non-negotiable.
|
||||||
|
3. **New paragraphs (fully inserted)**: Use `pPr/rPr/ins` mark to flag the ¶ itself as inserted, plus `w:ins` wrapping the text run. Both are needed for Word to show the full paragraph as tracked insertion.
|
||||||
|
4. **Verify files open correctly**: After save, always `Document(path)` to confirm no XML parse errors.
|
||||||
|
|
||||||
|
## Template (full working script structure)
|
||||||
|
|
||||||
|
See `/tmp/modify_v3_charlevel.py` from the 2026-07-07 session — processes two contracts (full-time + part-time) with char-level diff + comments injection. Pattern: `process_contract(input, output, is_fulltime=bool)`.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Strip numPr Before Inserting Manual Numbering (2026-07-01)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When adding manual numbering (e.g. "第一条 ") as `w:ins` at the beginning of a paragraph that already has `<w:numPr>` (automatic numbering like "%1." decimal format), OnlyOffice renders BOTH:
|
||||||
|
- The automatic number: "1."
|
||||||
|
- The manual INS text: "第一条"
|
||||||
|
|
||||||
|
Result: "1. 第一条 乙方应严格按照..."
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
`<w:numPr>` in pPr tells the rendering engine to prepend an auto-generated number. The `w:ins` text is just another run in the paragraph — it doesn't suppress the auto-numbering.
|
||||||
|
|
||||||
|
Additionally, `<w:pPrChange>` records the pre-revision pPr state. If pPrChange still contains `<w:numPr>`, some renderers will show the old numbering in markup view.
|
||||||
|
|
||||||
|
## Affected Scenarios
|
||||||
|
|
||||||
|
1. **反委托代发工资协议 (2026-07-01)**: Original paragraphs P6-P12, P19 had `numId=1` or `numId=3` (decimal "%1." format). After adding "第一条" through "第十一条" as INS, OnlyOffice showed "1. 第一条", "2. 第二条", etc.
|
||||||
|
|
||||||
|
2. **Any contract where the original used auto-numbering**: Check `numbering.xml` for active numId references with `numFmt=decimal` or `numFmt=chineseCounting`.
|
||||||
|
|
||||||
|
## Fix Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
import zipfile
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||||
|
|
||||||
|
with zipfile.ZipFile(docx_path, 'r') as z:
|
||||||
|
all_files = {name: z.read(name) for name in z.namelist()}
|
||||||
|
|
||||||
|
doc = etree.fromstring(all_files['word/document.xml'])
|
||||||
|
body = doc.find(f'{WNS}body')
|
||||||
|
paras = body.findall(f'{WNS}p')
|
||||||
|
|
||||||
|
# Identify paragraphs where we added manual numbering INS
|
||||||
|
# These are paragraphs that have both:
|
||||||
|
# 1. A w:ins with author=WB containing "第X条" text
|
||||||
|
# 2. A pPr with numPr
|
||||||
|
|
||||||
|
for i, p in enumerate(paras):
|
||||||
|
ppr = p.find(f'{WNS}pPr')
|
||||||
|
if ppr is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if this paragraph has our manual numbering INS
|
||||||
|
has_manual_numbering = False
|
||||||
|
for child in p:
|
||||||
|
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||||
|
if tag == 'ins' and child.get(f'{WNS}author') == 'WB':
|
||||||
|
text = ''.join(t.text for t in child.iter(f'{WNS}t') if t.text)
|
||||||
|
if '第' in text and '条' in text:
|
||||||
|
has_manual_numbering = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not has_manual_numbering:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Strip numPr from pPr
|
||||||
|
num_pr = ppr.find(f'{WNS}numPr')
|
||||||
|
if num_pr is not None:
|
||||||
|
ppr.remove(num_pr)
|
||||||
|
print(f"P{i}: Stripped numPr from pPr")
|
||||||
|
|
||||||
|
# Strip numPr from pPrChange
|
||||||
|
ppc = ppr.find(f'{WNS}pPrChange')
|
||||||
|
if ppc is not None:
|
||||||
|
inner_ppr = ppc.find(f'{WNS}pPr')
|
||||||
|
if inner_ppr is not None:
|
||||||
|
inner_num = inner_ppr.find(f'{WNS}numPr')
|
||||||
|
if inner_num is not None:
|
||||||
|
inner_ppr.remove(inner_num)
|
||||||
|
print(f"P{i}: Stripped numPr from pPrChange")
|
||||||
|
|
||||||
|
# Save back
|
||||||
|
all_files['word/document.xml'] = etree.tostring(doc, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||||
|
# Write to temp file then replace (never write to same zip you're reading)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After stripping, render with OnlyOffice and confirm:
|
||||||
|
1. Markup view shows only the manual numbering (no "1." prefix)
|
||||||
|
2. Accepted-revisions view shows clean "第一条" through "第十一条"
|
||||||
|
3. python-docx can still open the file without errors
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
- **DEL-only empty paragraphs** (e.g. P8, P9 where all content is w:del): These may still have numPr. If they render a visible "3." or "4." in the gap, strip those too.
|
||||||
|
- **Cross-paragraph clauses** (P6+P7 = one clause): P7 may have its own independent numPr even though it's a continuation paragraph. Strip it.
|
||||||
|
- **numId=0 (disabled numbering)**: `numId=0` in OOXML means "numbering OFF" — it doesn't render anything. Only strip numPr where `numId > 0` and the corresponding abstractNum has a visible numFmt (decimal, chineseCounting, etc.).
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Systematic File Recovery for Lost Author Markers
|
||||||
|
|
||||||
|
When intermediate files have been overwritten during iterative editing (e.g., multiple versions of a contract revision), and you need to find a specific version that contains tracked changes by a particular author (e.g., "华诚-Z"), use this systematic scan approach.
|
||||||
|
|
||||||
|
## Scenario
|
||||||
|
- You made multiple intermediate files (v1, v2, v3...) in `/tmp/` during contract editing
|
||||||
|
- You overwrote files, losing the version with a specific author's tracked changes
|
||||||
|
- You need to find ANY surviving file that still has that author's `w:author` attribute
|
||||||
|
|
||||||
|
## Recovery Technique
|
||||||
|
|
||||||
|
### Step 1: List all candidate files
|
||||||
|
Find all `.docx` files in the working directory that are newer than the original source file:
|
||||||
|
```bash
|
||||||
|
find /tmp -name '*.docx' -newer /tmp/original_file.docx 2>/dev/null | sort
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Check each file for the target author
|
||||||
|
```python
|
||||||
|
import zipfile, re, os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
target_author = '华诚-Z' # or whatever author you're looking for
|
||||||
|
|
||||||
|
files = [
|
||||||
|
"/tmp/v1_clean.docx",
|
||||||
|
"/tmp/v1_final.docx",
|
||||||
|
# ... list all candidate files from Step 1
|
||||||
|
]
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
if not os.path.exists(f):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
z = zipfile.ZipFile(f)
|
||||||
|
content = z.read('word/document.xml').decode('utf-8', 'ignore')
|
||||||
|
authors = set(re.findall(r'w:author="([^"]+)"', content))
|
||||||
|
mt = datetime.fromtimestamp(os.path.getmtime(f)).strftime('%m-%d %H:%M')
|
||||||
|
has_target = target_author in authors
|
||||||
|
marker = '★' if has_target else ' '
|
||||||
|
print(f"{marker} {os.path.basename(f):35s} {mt} authors={sorted(authors)}")
|
||||||
|
z.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR {f}: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Extract the target author's changes
|
||||||
|
Once you find the file with the target author, extract their specific tracked changes:
|
||||||
|
```python
|
||||||
|
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||||
|
|
||||||
|
z = zipfile.ZipFile('/tmp/file_with_target_author.docx')
|
||||||
|
with z.open('word/document.xml') as f:
|
||||||
|
tree = etree.parse(f)
|
||||||
|
root = tree.getroot()
|
||||||
|
body = root.find(f'{WNS}body')
|
||||||
|
paras = body.findall(f'{WNS}p')
|
||||||
|
|
||||||
|
for i, p in enumerate(paras):
|
||||||
|
has_target = False
|
||||||
|
parts = []
|
||||||
|
for child in p:
|
||||||
|
tag = etree.QName(child.tag).localname
|
||||||
|
if tag == 'r':
|
||||||
|
t = child.find(f'{WNS}t')
|
||||||
|
if t is not None and t.text:
|
||||||
|
parts.append(('RUN', t.text, None))
|
||||||
|
elif tag == 'ins':
|
||||||
|
author = child.get(f'{WNS}author', '?')
|
||||||
|
if target_author in author:
|
||||||
|
has_target = True
|
||||||
|
ins_texts = []
|
||||||
|
for r in child.findall(f'{WNS}r'):
|
||||||
|
t = r.find(f'{WNS}t')
|
||||||
|
if t is not None and t.text:
|
||||||
|
ins_texts.append(t.text)
|
||||||
|
if ins_texts:
|
||||||
|
parts.append(('INS', ''.join(ins_texts), author))
|
||||||
|
elif tag == 'del':
|
||||||
|
author = child.get(f'{WNS}author', '?')
|
||||||
|
if target_author in author:
|
||||||
|
has_target = True
|
||||||
|
del_texts = []
|
||||||
|
for r in child.findall(f'{WNS}r'):
|
||||||
|
t = r.find(f'{WNS}delText')
|
||||||
|
if t is not None and t.text:
|
||||||
|
del_texts.append(t.text)
|
||||||
|
if del_texts:
|
||||||
|
parts.append(('DEL', ''.join(del_texts), author))
|
||||||
|
|
||||||
|
if has_target:
|
||||||
|
print(f"\n★ P{i}:")
|
||||||
|
for kind, text, author in parts:
|
||||||
|
if kind == 'RUN':
|
||||||
|
print(f" [原文] {repr(text)}")
|
||||||
|
else:
|
||||||
|
print(f" [{kind} by {author}] {repr(text)}")
|
||||||
|
|
||||||
|
z.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Empirical Case (2026-07-01 反委托代发工资协议)
|
||||||
|
- Made ~15 intermediate files in `/tmp/` during iterative editing
|
||||||
|
- Overwrote all files, changing all `w:author` attributes to "WB"
|
||||||
|
- User (Doro) demanded recovery of 华诚-Z's tracked changes
|
||||||
|
- Systematic scan found `/tmp/v1_doro_updated.docx` with `authors=['WB', '华诚-Z']`
|
||||||
|
- Extracted 华诚-Z's 3 specific changes:
|
||||||
|
- P6: INS "等" (between WB's "《劳务派遣暂行规定》" and "规定,")
|
||||||
|
- P12: INS "退回派遣员工" + INS "由乙方依法自行安置处理,与甲方无涉。"
|
||||||
|
|
||||||
|
## Key Pitfalls
|
||||||
|
1. **Don't assume the file is gone** — check ALL intermediate files, not just the ones you expect
|
||||||
|
2. **Check timestamps** — the file you need might be an early intermediate, not the latest
|
||||||
|
3. **Use `w:author` attribute** — this is the definitive marker, not file content or naming
|
||||||
|
4. **Comments may also be lost** — the recovered file might have lost some original comments (see `references/comment-restoration-from-original.md`)
|
||||||
|
|
||||||
|
## Prevention (Better Than Recovery)
|
||||||
|
The existing skill already covers this, but worth repeating: **改前必备份** — before modifying any file with third-party tracked changes, save a timestamped backup:
|
||||||
|
```bash
|
||||||
|
cp file_with_third_party.docx file_with_third_party.bak_$(date +%Y%m%d_%H%M%S).docx
|
||||||
|
```
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 表格单元格编辑:保持格式不被破坏
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
编辑 docx 表格单元格中的文字时,两种常见错误做法都会破坏格式:
|
||||||
|
|
||||||
|
1. **`cell.paragraphs[0].clear()` + `add_run()`**:把多段结构压成一段,丢失加粗、字号、字体
|
||||||
|
2. **XML 层全 cell 文字重分片**:把修改后的文字均匀分配到所有 `w:t` 元素,破坏段落边界和编号
|
||||||
|
|
||||||
|
## 正确做法:段落级精确定位 + 只改目标段
|
||||||
|
|
||||||
|
```python
|
||||||
|
from docx import Document
|
||||||
|
from docx.shared import Pt
|
||||||
|
|
||||||
|
doc = Document('file.docx')
|
||||||
|
table = doc.tables[0]
|
||||||
|
cell = table.rows[7].cells[1]
|
||||||
|
|
||||||
|
# 1. 定位目标段落(按索引)
|
||||||
|
paras = cell.paragraphs
|
||||||
|
target_p = paras[4] # 例如 P4 是你要改的段落
|
||||||
|
|
||||||
|
# 2. 保存首 run 格式
|
||||||
|
first_run = target_p.runs[0]
|
||||||
|
saved = {
|
||||||
|
'name': first_run.font.name,
|
||||||
|
'size': first_run.font.size,
|
||||||
|
'bold': first_run.font.bold,
|
||||||
|
'italic': first_run.font.italic,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. 文字替换
|
||||||
|
old_text = target_p.text
|
||||||
|
new_text = old_text.replace('要被替换的文字', '新文字')
|
||||||
|
|
||||||
|
# 4. 清空该段 → 重写(保留格式)
|
||||||
|
target_p.clear()
|
||||||
|
run = target_p.add_run(new_text)
|
||||||
|
run.font.name = saved['name']
|
||||||
|
run.font.size = saved['size']
|
||||||
|
run.font.bold = saved['bold']
|
||||||
|
run.font.italic = saved['italic']
|
||||||
|
|
||||||
|
# 5. 合并单元格:同步更新同行其他 cell
|
||||||
|
for col_idx in [2, 3]:
|
||||||
|
cell2 = table.rows[7].cells[col_idx]
|
||||||
|
p = cell2.paragraphs[4]
|
||||||
|
p.clear()
|
||||||
|
run = p.add_run(new_text)
|
||||||
|
run.font.name = saved['name']
|
||||||
|
run.font.size = saved['size']
|
||||||
|
|
||||||
|
doc.save('output.docx')
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键原则
|
||||||
|
|
||||||
|
- **不碰其他段落**:只改目标索引的段落,其余段落原封不动
|
||||||
|
- **不压多段为一段**:每个段落独立处理,保持 `P0/P1/P2/P3/P4` 结构不变
|
||||||
|
- **合并单元格全同步**:`row[7].cells[1]` 改了什么,`cells[2]`、`cells[3]` 也要同步
|
||||||
|
- **先读后改**:改前用 `cell.paragraphs[i].text` 确认内容,用 `cell.paragraphs[i].runs[0].font` 确认格式
|
||||||
|
|
||||||
|
## 本次教训
|
||||||
|
|
||||||
|
2026-06-26 预算绩效分析表:两轮都搞坏格式。
|
||||||
|
- 第一轮:`clear()` + `add_run()` 把 Row 7 的 P0-P10 多段结构压成一段
|
||||||
|
- 第二轮:XML 全 cell 文字重分片把 "2.成本核算分析" 变成 ".成本核算分析"(编号丢失)
|
||||||
|
- 第三轮(正确):定位到 P4(成本优化段),只改它,保留 P0-P3 不动
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# tracked_replace 被 DEL 元素打断(2026-06-26 CT维保合同-香花桥实证)
|
||||||
|
|
||||||
|
## 症状
|
||||||
|
|
||||||
|
`tracked_replace(old, new)` 对跨 DEL 元素的文本静默失败(不报错但也不修改)。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
当匹配文本被拆成多个 run,且中间夹着 `<w:del>` 元素时,`tracked_replace` 无法跨元素边界匹配完整字符串。
|
||||||
|
|
||||||
|
## 实证
|
||||||
|
|
||||||
|
**场景**:原文"一 年"(中间有空格),需改为"一年"。
|
||||||
|
|
||||||
|
实际 XML 结构:
|
||||||
|
```xml
|
||||||
|
<w:r><w:t>一</w:t></w:r>
|
||||||
|
<w:del><w:r><w:delText> </w:delText></w:r></w:del>
|
||||||
|
<w:r><w:t>年</w:t></w:r>
|
||||||
|
```
|
||||||
|
|
||||||
|
`tracked_replace("一 年", "一年")` 无法匹配("一 年" 不连续存在于任何单一 run 中)。
|
||||||
|
|
||||||
|
## 修法
|
||||||
|
|
||||||
|
直接 zipfile+lxml 操作,移除 DEL 元素:
|
||||||
|
|
||||||
|
```python
|
||||||
|
for elem in list(paragraph):
|
||||||
|
if elem.tag.split('}')[-1] == 'del':
|
||||||
|
for t in elem.iter():
|
||||||
|
if t.tag == f'{{{W}}}delText' and t.text == ' ':
|
||||||
|
paragraph.remove(elem)
|
||||||
|
break
|
||||||
|
```
|
||||||
|
|
||||||
|
## 同类变体:INS 需插入在 DEL 之后
|
||||||
|
|
||||||
|
**场景**:原文"与济损失"("与"为错字),上一轮已将"与"包进 DEL,但未补 INS "经"。
|
||||||
|
|
||||||
|
实际 XML 结构:
|
||||||
|
```xml
|
||||||
|
<w:del><w:r><w:delText>与</w:delText></w:r></w:del>
|
||||||
|
<w:r><w:t>济损失的...</w:t></w:r>
|
||||||
|
```
|
||||||
|
|
||||||
|
`tracked_replace("与济损失", "经济损失")` 静默失败("与"在 DEL 内)。
|
||||||
|
|
||||||
|
**修法**:zipfile+lxml 在 DEL 元素后插入 INS:
|
||||||
|
```python
|
||||||
|
ins = etree.SubElement(paragraph, f'{{{W}}}ins')
|
||||||
|
ins.set(f'{{{W}}}id', str(new_id))
|
||||||
|
ins.set(f'{{{W}}}author', 'WB')
|
||||||
|
ins.set(f'{{{W}}}date', date_str)
|
||||||
|
del_elem.addnext(ins) # INS 紧跟在 DEL 之后
|
||||||
|
|
||||||
|
r = etree.SubElement(ins, f'{{{W}}}r')
|
||||||
|
r.append(copy.deepcopy(ref_rPr)) # 从同级 run 克隆 rPr
|
||||||
|
t = etree.SubElement(r, f'{{{W}}}t')
|
||||||
|
t.text = '经'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 判别
|
||||||
|
|
||||||
|
改前先遍历目标段落子元素,看是否有 `w:del` 或 `w:ins` 元素分割了匹配文本。有则不用 `tracked_replace`,改用 zipfile+lxml 直接操作。
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# tracked_replace 跨 w:ins 元素失败的处理
|
||||||
|
|
||||||
|
## 症状
|
||||||
|
|
||||||
|
`tracked_replace(old, new)` 抛出 `ValueError: Element is not a child of this node`,
|
||||||
|
发生在 `contract_docx_lib.py` 第368行 `parent.remove(runs[idx])`。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
合同已经过上一轮 workflow 修订,原文段落中插入了 `w:ins(author="WB")` 元素。
|
||||||
|
目标匹配文本跨越了 `w:r` 和 `w:ins` 边界:
|
||||||
|
|
||||||
|
```
|
||||||
|
w:r: "...若甲方在双"
|
||||||
|
w:ins(author="WB"): "方"
|
||||||
|
w:r: "核对消费金额时未提出异议的..."
|
||||||
|
```
|
||||||
|
|
||||||
|
`tracked_replace` 把 `w:r` 和 `w:ins` 下的 `w:r` 都收集到 `runs` 列表,
|
||||||
|
但 `parent.remove(runs[idx])` 时,`w:ins` 内的 `w:r` 的 parent 是 `w:ins`,不是 `w:p` → 报错。
|
||||||
|
|
||||||
|
## 判别
|
||||||
|
|
||||||
|
修改前先遍历目标段落的子元素,看是否有 `w:ins` 分割了匹配文本:
|
||||||
|
|
||||||
|
```python
|
||||||
|
for elem in paragraph:
|
||||||
|
tag = elem.tag.split('}')[-1]
|
||||||
|
if tag in ('r', 'ins', 'del'):
|
||||||
|
print(f" {tag}: '{''.join(t.text or '' for t in elem.findall('.//{W}t'))}'")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 修法:zipfile+lxml 直接操作
|
||||||
|
|
||||||
|
不用 `tracked_replace`,改用 zipfile+lxml 四步操作:
|
||||||
|
|
||||||
|
### 步骤1:裁掉第一段 run 中跨越的部分
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 如:w:r 末尾是 "...核对确认,若甲方在双" → 裁掉 "若甲方在双"
|
||||||
|
assert r1_t.text.endswith('若甲方在双')
|
||||||
|
r1_t.text = r1_t.text[:-6] # 移除6个字符
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤2:创建 DEL 包裹被裁掉的文字
|
||||||
|
|
||||||
|
```python
|
||||||
|
del_elem = etree.Element(qn('del'))
|
||||||
|
del_elem.set(qn('id'), str(next_del_id))
|
||||||
|
del_elem.set(qn('author'), 'WB')
|
||||||
|
del_elem.set(qn('date'), revision_date)
|
||||||
|
|
||||||
|
del_run = etree.SubElement(del_elem, qn('r'))
|
||||||
|
del_rpr = etree.SubElement(del_run, qn('rPr'))
|
||||||
|
# 从被裁 run 复制 rPr
|
||||||
|
for child in r1_elem.find(qn('rPr')):
|
||||||
|
del_rpr.append(copy.deepcopy(child))
|
||||||
|
del_text = etree.SubElement(del_run, qn('delText'))
|
||||||
|
del_text.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||||
|
del_text.text = '若甲方在双方' # 被裁文字 + w:ins 内容
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤3:移除原来的 w:ins 元素
|
||||||
|
|
||||||
|
```python
|
||||||
|
p.remove(ins_elem)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤4:DEL旧文字 + INS新文字
|
||||||
|
|
||||||
|
```python
|
||||||
|
# DEL 旧文字(第三段 run 的完整内容)
|
||||||
|
del_elem2 = ... # 同上模式,delText = r3_t.text
|
||||||
|
# INS 新文字
|
||||||
|
ins_elem = etree.Element(qn('ins'))
|
||||||
|
ins_elem.set(qn('id'), str(next_ins_id))
|
||||||
|
ins_elem.set(qn('author'), 'WB')
|
||||||
|
ins_elem.set(qn('date'), revision_date)
|
||||||
|
ins_run = etree.SubElement(ins_elem, qn('r'))
|
||||||
|
# 从原 run 复制 rPr
|
||||||
|
ins_rpr = etree.SubElement(ins_run, qn('rPr'))
|
||||||
|
for child in r3_elem.find(qn('rPr')):
|
||||||
|
ins_rpr.append(copy.deepcopy(child))
|
||||||
|
ins_t = etree.SubElement(ins_run, qn('t'))
|
||||||
|
ins_t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||||
|
ins_t.text = new_text
|
||||||
|
|
||||||
|
# 替换:在 r3 位置前插入 DEL 和 INS,再删除 r3
|
||||||
|
r3_pos = list(p).index(r3_elem)
|
||||||
|
p.insert(r3_pos, del_elem2)
|
||||||
|
p.insert(r3_pos + 1, ins_elem)
|
||||||
|
p.remove(r3_elem)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤5:写回
|
||||||
|
|
||||||
|
```python
|
||||||
|
doc_xml_modified = etree.tostring(tree, encoding='UTF-8', xml_declaration=True)
|
||||||
|
with zipfile.ZipFile(src, 'r') as zf:
|
||||||
|
file_data = {f: zf.read(f) for f in zf.namelist()}
|
||||||
|
with zipfile.ZipFile(src, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for f, data in file_data.items():
|
||||||
|
zf.writestr(f, doc_xml_modified if f == 'word/document.xml' else data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 易错点
|
||||||
|
|
||||||
|
### 1. 中文切片长度
|
||||||
|
`[:-3]` 移除3个**字符**(不是字节)。"但本" = 2个中文字符 → 用 `[:-2]`。
|
||||||
|
用 `[:-3]` 会多切一个字(如把";"也切掉)。
|
||||||
|
|
||||||
|
**⚠️ 全角标点也是1个字符(2026-06-26 健康积分兑换协议教训)**:`(四)` 是3个字符——`(`(U+FF08全角左括号=1字)、`四`(1字)、`)`(U+FF09全角右括号=1字)。用 `[4:]` 切片会多切掉1个中文字符,导致 DEL 文本缺字("甲方"→"方")。**判别**:数切片偏移时,全角括号/标点(()、【】、《》、。,!?等)每符1字,不因"看起来宽"就计为多个。切片前 `print(repr(text[:10]))` 确认边界。
|
||||||
|
|
||||||
|
### 2. 旧文末尾与新文开头重复
|
||||||
|
原文 run 裁掉部分后,末尾可能与 INS 新文字开头重复。
|
||||||
|
如:原文末尾是"核对确认",新文字开头也是"核对确认" → 出现"核对确认核对确认"。
|
||||||
|
**修法**:从 INS 新文字中去掉重复前缀。
|
||||||
|
|
||||||
|
### 3. 标点符号归属
|
||||||
|
裁掉 run 末尾文字时,确保标点符号(;。等)留在正确位置。
|
||||||
|
如原文"费用;但本"裁掉"但本"后应为"费用;"(保留分号)。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
1. `python-docx` 能打开(`Document(out)` 不抛异常)
|
||||||
|
2. `wb-ins-font-verify.py` 所有 WB INS 字体一致
|
||||||
|
3. 用 `ContractEditor.get_para_text()` 读段落文本,确认无重复/缺字
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 移植workflow修订到同名不同版本合同 (2026-07-08)
|
||||||
|
|
||||||
|
## 场景
|
||||||
|
邱律师同日发了两份同名文件(如"2026年华新镇公立中小学生健康体检服务合同.docx"),内容有实质差异(不同版本/条款)。第一份被workflow正常审查交付,第二份因queue-runner同名跳过逻辑被遗漏。Doro要求"把workflow第1份的修订内容直接修订到第2份里,但要注意相关修订在第2份里是否合理"。
|
||||||
|
|
||||||
|
## 操作步骤
|
||||||
|
|
||||||
|
### 1. 提取第1份的WB修订清单
|
||||||
|
从已交付文件提取所有 `author=WB` 的 `w:ins` 和 `w:del`:
|
||||||
|
```python
|
||||||
|
from zipfile import ZipFile
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
with ZipFile(delivered_path, 'r') as z:
|
||||||
|
content = z.read('word/document.xml').decode('utf-8')
|
||||||
|
root = etree.fromstring(content.encode('utf-8'))
|
||||||
|
# 遍历所有 WB INS/DEL,记录:段落索引、INS文本、DEL文本、上下文
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 对比两份合同差异
|
||||||
|
用 `difflib` 对比两版全文,定位哪些段落内容不同。特别关注:
|
||||||
|
- 修订涉及的段落在第2份中是否存在
|
||||||
|
- 如果存在,文本是否与第1份中的"修订前"文本一致
|
||||||
|
|
||||||
|
### 3. 逐条判断修订是否适用于第2份
|
||||||
|
| 修订类型 | 判断方法 |
|
||||||
|
|---------|---------|
|
||||||
|
| 术语统一(如"协议"→"合同") | 在第2份中搜索同一术语,存在则同样修改 |
|
||||||
|
| 新增保护条款(如数据归属、转包连带责任) | 检查第2份对应位置是否缺同样的保护,缺则加 |
|
||||||
|
| 金额/支付相关修订 | 第2份的支付条款可能完全不同(如本案),需独立判断是否需要新的修订 |
|
||||||
|
| 合同期限相关修订 | 第2份期限可能不同,独立判断 |
|
||||||
|
|
||||||
|
### 4. 对第2份执行修订
|
||||||
|
使用ContractEditor库,与正常审查相同流程:
|
||||||
|
```python
|
||||||
|
ed = ContractEditor(second_file)
|
||||||
|
ed.tracked_replace(old, new) # 逐条适用的修订
|
||||||
|
errors = ed.validate()
|
||||||
|
ed.save(output)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 字体验证 + 上传
|
||||||
|
- `wb-ins-font-verify.py` 必须PASS
|
||||||
|
- 上传替换NC任务交付目录中的同名文件
|
||||||
|
- 同时确保第2份原始文件在待审查目录
|
||||||
|
|
||||||
|
## 2026-07-08 华新镇体检合同实证
|
||||||
|
|
||||||
|
**两版差异:**
|
||||||
|
| 条款 | 第1份 (11:05) | 第2份 (16:04) |
|
||||||
|
|------|--------------|--------------|
|
||||||
|
| 项目内容 | "公立中小学生健康检查工作" | "华新镇公立中小学生健康体检工作" |
|
||||||
|
| 支付方式 | 按实际人数结算,无金额上限 | 按实际完成人数+考核表结算,费用上限17万 |
|
||||||
|
| 合同期限 | 9月10日起 | 9月1日起 |
|
||||||
|
|
||||||
|
**移植的修订(全部适用):**
|
||||||
|
1. 保密条款:数据归属+合同期满扩大+协议→合同统一 ✅ 第2份保密条款内容相同
|
||||||
|
2. 转包限制:增加甲方书面同意+连带责任 ✅ 第2份P44文本相同
|
||||||
|
3. 效力条款:本协议→本合同 ✅ 第2份P51文本相同
|
||||||
|
|
||||||
|
**不需要额外修订的原因:**
|
||||||
|
- 第2份的支付条款已更完善(有上限、有考核、有一次性付清约定)
|
||||||
|
- 违约责任、争议解决条款相同且已足够
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
- 第2份被上传后会**替换**第1份的交付文件(同名),tracker中seq=262的记录对应的实际内容变了
|
||||||
|
- hint mismatch 在 tracked_replace 生成的长INS文本中常见(库不自动加hint到多段INS),需post-fix
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 版本管理反模式(2026-07-01 反委托代发工资协议惨痛教训)
|
||||||
|
|
||||||
|
## 事件回顾
|
||||||
|
|
||||||
|
反委托代发工资协议需要制作两个版本(法定安排 vs 反委托保护),同时保留华诚-Z的修订痕迹。
|
||||||
|
|
||||||
|
### 灾难链条
|
||||||
|
|
||||||
|
1. 原始文件有华诚-Z的修订(author="华诚-Z")+ 批注
|
||||||
|
2. 我制作WB版本时,把所有author改成了WB
|
||||||
|
3. 又做了一版合并版本,再次覆盖
|
||||||
|
4. 之后Doro说"你把华诚-Z修订痕迹的版本放进去"
|
||||||
|
5. 发现/tmp里所有文件都只有WB作为author
|
||||||
|
6. Nextcloud版本历史也没有(只保留了一个.v文件,也是WB)
|
||||||
|
7. 最终在 `/tmp/v1_doro_updated.docx` 找到——这是一个中间版本,纯属侥幸
|
||||||
|
|
||||||
|
### 反模式清单
|
||||||
|
|
||||||
|
| 反模式 | 后果 |
|
||||||
|
|--------|------|
|
||||||
|
| 修改author前不备份 | 原始修订痕迹不可逆丢失 |
|
||||||
|
| 覆盖式保存(同文件名) | 中间版本消失 |
|
||||||
|
| 从头重做而非增量修补 | 每次重做都覆盖上一版 |
|
||||||
|
| 不验证就交付 | 批注丢了3条没发现 |
|
||||||
|
| 多轮操作共用/tmp目录 | 后续操作的文件名与前面冲突 |
|
||||||
|
|
||||||
|
### 正确做法
|
||||||
|
|
||||||
|
```python
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 操作前备份
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
shutil.copy(source, f"{source}.bak_{timestamp}")
|
||||||
|
|
||||||
|
# 操作后验证
|
||||||
|
import zipfile, re
|
||||||
|
z = zipfile.ZipFile(output)
|
||||||
|
content = z.read('word/document.xml').decode('utf-8')
|
||||||
|
authors = set(re.findall(r'w:author="([^"]+)"', content))
|
||||||
|
assert '华诚-Z' in authors, "华诚-Z author LOST!"
|
||||||
|
|
||||||
|
# 批注验证
|
||||||
|
if 'word/comments.xml' in z.namelist():
|
||||||
|
comments_xml = z.read('word/comments.xml').decode('utf-8')
|
||||||
|
comment_count = len(re.findall(r'<w:comment ', comments_xml))
|
||||||
|
assert comment_count >= expected_count, f"Comments lost: {comment_count} < {expected_count}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件命名规范(防覆盖)
|
||||||
|
|
||||||
|
不要用 `_v2.docx` `_v3.docx` 这种递增命名——容易忘记当前版本是几。用语义+时间戳:
|
||||||
|
|
||||||
|
```
|
||||||
|
反委托_华诚Z原版_20260701_0320.docx # 带华诚-Z修订的版本
|
||||||
|
反委托_WB合并版_20260701_0341.docx # WB+华诚-Z合并后
|
||||||
|
反委托_V1法定安排_FINAL_20260701.docx # 最终交付
|
||||||
|
```
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# WB自加手动编号与前序自动编号撞号 — 诊断与修复
|
||||||
|
|
||||||
|
实战来源:端午节福利品采购合同(2026-06-16)。Maggie:"转包责任应该是7,上一个编号是6。手动修复上传。"
|
||||||
|
|
||||||
|
## 场景识别
|
||||||
|
- 我们(WB)用 `w:ins` 新增了若干尾部条款(转包/违约/争议…),编号是**手动文字**写在 run 文本开头("6、转包限制…")。
|
||||||
|
- 紧邻的前一条是**原文自带的自动编号**条款(pPr 有 `<w:numPr>`,编号由 numbering.xml 的 `<w:start>` 生成,文字里**没有**编号)。
|
||||||
|
- 二者渲染数字撞号:自动编号末值=6,我方手动也从6起 → 接受修订后出现两个6。
|
||||||
|
|
||||||
|
## 诊断步骤(顺序不可颠倒,OnlyOffice为准)
|
||||||
|
1. 取交付版(任务交付/【修】…docx)+原文(待审查/…doc),各用 `scripts/onlyoffice-render.sh` 渲染 PDF。
|
||||||
|
2. `pdftotext -layout x.pdf - | grep -E "^\s*\f?[0-9]+、"` 数出**完整可见编号链**(注意"5、结算"可能挤在第4条段内、自动编号条款文字里无编号——肉眼易漏)。
|
||||||
|
3. 读 numbering.xml 确认前序自动条款的 numId→abstractNumId→lvl0 的 `start` 值,得知它渲染成几(端午节:numId=3, start=6 →"6")。
|
||||||
|
4. 读 document.xml,确认我方各条是 `w:ins author=WB`,编号"6、""7、""8、"在 ins 首个 w:r 的 w:t 开头。
|
||||||
|
|
||||||
|
## 判定
|
||||||
|
前序自动编号末值 = N → 我方手动编号应从 **N+1** 起顺延。端午节:售后=6 → 转包=7、违约=8、争议=9。
|
||||||
|
**有Maggie明确指示 + 完整核对 → 执行。** 不要因"擅改编号"的旧教训而拒绝正确修复(区别在:当初错在没核对没确认,不在方向)。
|
||||||
|
|
||||||
|
## 修复(纯 zipfile+lxml,最干净)
|
||||||
|
只改 ins 首个 w:t 的编号前缀,不拆 run、不碰 rPr、不转 numbering:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import zipfile, os
|
||||||
|
from lxml import etree
|
||||||
|
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||||
|
src='deliver.docx'; out='deliver_FIXED.docx'
|
||||||
|
root = etree.fromstring(zipfile.ZipFile(src).read('word/document.xml'))
|
||||||
|
paras = root.find(f'{W}body').findall(f'{W}p')
|
||||||
|
changes = {19:('6、','7、','转包'), 20:('7、','8、','违约'), 21:('8、','9、','争议')} # 段索引→(旧号,新号,关键词)
|
||||||
|
for idx,(old,new,kw) in changes.items():
|
||||||
|
ins = paras[idx].find(f'{W}ins')
|
||||||
|
assert ins is not None and ins.get(f'{W}author')=='WB', f"段{idx}非WB的ins!" # 铁律:绝不改他人ins
|
||||||
|
t = ins.find(f'{W}r').find(f'{W}t')
|
||||||
|
assert t.text.startswith(old) and kw in t.text[:6]
|
||||||
|
t.text = new + t.text[len(old):]
|
||||||
|
new_doc = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||||
|
tmp=out+'.tmp'
|
||||||
|
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(tmp,'w',zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for it in zin.infolist():
|
||||||
|
zout.writestr(it, new_doc if it.filename=='word/document.xml' else zin.read(it.filename))
|
||||||
|
os.replace(tmp,out)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 交付前四查(vision不可用时的强制验证,缺一不可)
|
||||||
|
1. **逐段markup diff vs交付源**:提取两版每个 w:p 的 markup 文本(含 delText),断言**只有目标N段不同**、其余全部零改动(端午节33段只动3段)。
|
||||||
|
2. **OnlyOffice渲染PDF编号链**:pdftotext 数出 1,2,3,4,(5),6,7,8,9 连续无双号。
|
||||||
|
3. **INS run rPr 改前==改后**:`etree.tostring(rpr)` 逐段比对,确认字体/字号一字未动。
|
||||||
|
4. **python-docx 能打开** + 接受修订后(去 del、解包 ins)编号链连续,证明 XML 合法、WB 修订标记完整保留。
|
||||||
|
|
||||||
|
## 交付(Editor到此为止则交deliverer;本例Maggie直接要"上传"故一并做)
|
||||||
|
- 文件名**一字不动**:覆盖 `Doro合同审查任务/任务交付/【修】<原名>.docx`。
|
||||||
|
- `docker cp` 进 nextcloud-nextcloud-1 → `chown www-data` → `occ files:scan --path=...`。
|
||||||
|
- 落盘 md5 == 修复版 md5 才算成功。
|
||||||
|
- 清 OnlyOffice 缓存:`docker exec nextcloud-onlyoffice-1 rm -rf .../App_Data/cache/files/*`。
|
||||||
|
- 已 pass 登记过的合同仅编号订正:tracker/xlsx 记录不变动。
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# ContractEditor 原文Run属性污染诊断与修复
|
||||||
|
|
||||||
|
## 2026-07-13 洋励合同实证
|
||||||
|
|
||||||
|
### 问题描述
|
||||||
|
|
||||||
|
ContractEditor(contract_docx_lib.py)在处理文档时,不仅给WB INS runs添加多余属性,还会**修改原文runs**的rPr——给本来靠docDefaults/style继承的orig runs添加显式eastAsia/cs/sz。
|
||||||
|
|
||||||
|
### 典型污染模式
|
||||||
|
|
||||||
|
| 属性 | 原文(待审查) | 被污染后(交付物中的orig run) | WB INS run |
|
||||||
|
|------|--------------|-------------------------------|-----------|
|
||||||
|
| eastAsia | None (继承minorEastAsia) | **宋体** (被加) | None |
|
||||||
|
| cs | None | **宋体** (被加) | None |
|
||||||
|
| sz | None (继承docDefaults=22) | **21** (被加且值错) | None |
|
||||||
|
| ascii | 宋体 | 宋体 | None |
|
||||||
|
| hint | eastAsia (部分有) | eastAsia | None |
|
||||||
|
|
||||||
|
### 后果
|
||||||
|
|
||||||
|
1. 原文所有文字从11pt(docDefaults sz=22)变成10.5pt(显式sz=21) — 整体缩小0.5pt
|
||||||
|
2. INS文字没有任何属性 → 走docDefaults 11pt → 与被改小的原文不一致
|
||||||
|
3. 字体验证脚本(wb-ins-font-verify.py)报INS缺属性,但实际问题是orig被污染
|
||||||
|
|
||||||
|
### 诊断步骤
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 读原文代表性段落run rPr
|
||||||
|
python3 -c "
|
||||||
|
import zipfile
|
||||||
|
from lxml import etree
|
||||||
|
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||||
|
with zipfile.ZipFile('原文.docx', 'r') as z:
|
||||||
|
...
|
||||||
|
# 检查: eastAsia=None? sz=None?
|
||||||
|
# 如果是 → 原文靠继承
|
||||||
|
|
||||||
|
# 2. 读交付物同段落orig run rPr
|
||||||
|
# 检查: 是否多了eastAsia/cs/sz?
|
||||||
|
# 如果是 → 被污染
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修复代码模板
|
||||||
|
|
||||||
|
```python
|
||||||
|
import zipfile, tempfile, shutil
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||||
|
|
||||||
|
with zipfile.ZipFile(filepath, 'r') as z:
|
||||||
|
all_files = {n: z.read(n) for n in z.namelist()}
|
||||||
|
|
||||||
|
tree = etree.fromstring(all_files['word/document.xml'])
|
||||||
|
body = tree.find(f'{WNS}body')
|
||||||
|
|
||||||
|
# Step 1: Strip contaminated attributes from ALL orig runs
|
||||||
|
for p in body.findall(f'{WNS}p'):
|
||||||
|
for r in p.findall(f'{WNS}r'): # Only direct child runs (not inside ins/del)
|
||||||
|
rpr = r.find(f'{WNS}rPr')
|
||||||
|
if rpr is None:
|
||||||
|
continue
|
||||||
|
rf = rpr.find(f'{WNS}rFonts')
|
||||||
|
if rf is not None:
|
||||||
|
# Strip eastAsia (original didn't have it)
|
||||||
|
if f'{WNS}eastAsia' in rf.attrib:
|
||||||
|
del rf.attrib[f'{WNS}eastAsia']
|
||||||
|
# Strip cs (original didn't have it)
|
||||||
|
if f'{WNS}cs' in rf.attrib:
|
||||||
|
del rf.attrib[f'{WNS}cs']
|
||||||
|
# Strip sz (original relies on docDefaults)
|
||||||
|
sz = rpr.find(f'{WNS}sz')
|
||||||
|
if sz is not None:
|
||||||
|
rpr.remove(sz)
|
||||||
|
|
||||||
|
# Step 2: Fix INS runs to match REAL original format
|
||||||
|
for ins in body.findall(f'.//{WNS}ins'):
|
||||||
|
if ins.get(f'{WNS}author') != 'WB':
|
||||||
|
continue
|
||||||
|
for r in ins.findall(f'{WNS}r'):
|
||||||
|
rpr = r.find(f'{WNS}rPr')
|
||||||
|
if rpr is None:
|
||||||
|
continue
|
||||||
|
rf = rpr.find(f'{WNS}rFonts')
|
||||||
|
if rf is None:
|
||||||
|
rf = etree.SubElement(rpr, f'{WNS}rFonts')
|
||||||
|
# Match real original: ascii=宋体, hAnsi=宋体, NO eastAsia
|
||||||
|
rf.set(f'{WNS}ascii', '宋体')
|
||||||
|
rf.set(f'{WNS}hAnsi', '宋体')
|
||||||
|
if f'{WNS}eastAsia' in rf.attrib:
|
||||||
|
del rf.attrib[f'{WNS}eastAsia']
|
||||||
|
# Remove sz (let it inherit)
|
||||||
|
sz = rpr.find(f'{WNS}sz')
|
||||||
|
if sz is not None:
|
||||||
|
rpr.remove(sz)
|
||||||
|
|
||||||
|
# Step 3: Per-paragraph hint matching
|
||||||
|
for p in body.findall(f'{WNS}p'):
|
||||||
|
# Get orig run's hint
|
||||||
|
orig_hint = None
|
||||||
|
for r in p.findall(f'{WNS}r'):
|
||||||
|
rpr = r.find(f'{WNS}rPr')
|
||||||
|
if rpr is not None:
|
||||||
|
rf = rpr.find(f'{WNS}rFonts')
|
||||||
|
orig_hint = rf.get(f'{WNS}hint') if rf is not None else None
|
||||||
|
break
|
||||||
|
# Apply to INS runs in same paragraph
|
||||||
|
for ins in p.findall(f'.//{WNS}ins'):
|
||||||
|
if ins.get(f'{WNS}author') != 'WB':
|
||||||
|
continue
|
||||||
|
for r in ins.findall(f'{WNS}r'):
|
||||||
|
rpr = r.find(f'{WNS}rPr')
|
||||||
|
if rpr is None: continue
|
||||||
|
rf = rpr.find(f'{WNS}rFonts')
|
||||||
|
if rf is None: continue
|
||||||
|
if orig_hint:
|
||||||
|
rf.set(f'{WNS}hint', orig_hint)
|
||||||
|
elif f'{WNS}hint' in rf.attrib:
|
||||||
|
del rf.attrib[f'{WNS}hint']
|
||||||
|
```
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
1. **必须对比原文确定被污染了哪些属性** — 不同合同模板的原文属性不同
|
||||||
|
2. **不是所有合同都有此问题** — 取决于原文是否靠继承(有显式属性的不会被"污染",因为值相同)
|
||||||
|
3. **Step 1必须在Step 2之前** — 否则wb-ins-font-verify仍会报INS与(被污染的)orig不一致
|
||||||
|
4. **hint要逐段处理** — 同一文档不同段落的orig runs可能有的有hint有的没有
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Workflow INS Format Repair — ContractEditor Font Contamination Pattern
|
||||||
|
|
||||||
|
## 2026-07-13 洋励/安全生产/消防设施检测 连续验证
|
||||||
|
|
||||||
|
### 问题根因
|
||||||
|
|
||||||
|
ContractEditor库在处理文档时会**污染原文runs**——给原本没有显式属性的runs添加`eastAsia`、`cs`、`sz`。
|
||||||
|
|
||||||
|
典型对比:
|
||||||
|
```
|
||||||
|
原文(待审查): rFonts={ascii=宋体, hAnsi=宋体, hint=eastAsia}, szCs=21, NO sz, NO eastAsia
|
||||||
|
v1中orig runs: rFonts={ascii=宋体, hAnsi=宋体, hint=eastAsia, cs=宋体, eastAsia=宋体}, szCs=21, sz=21
|
||||||
|
v1中WB INS: rPr=空 (什么属性都没有)
|
||||||
|
```
|
||||||
|
|
||||||
|
**后果**:
|
||||||
|
1. 原文字号从继承docDefaults(如sz=22=11pt)变为显式sz=21(10.5pt)——整体缩小0.5pt
|
||||||
|
2. INS runs无属性→走docDefaults继承→11pt,与被改小的orig runs(10.5pt)不一致
|
||||||
|
3. wb-ins-font-verify报"orig=宋体/21 wb=None"——但这个"orig"已被污染,不是真实原文
|
||||||
|
|
||||||
|
### 诊断铁律
|
||||||
|
|
||||||
|
**永远对比待审查目录的原文,不信v1中的orig runs**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 对比同一段落的run属性
|
||||||
|
for label, path in [('待审查原文', orig_path), ('交付v1', v1_path)]:
|
||||||
|
# 读P4 first run rPr的所有子元素
|
||||||
|
# 如果v1比原文多了eastAsia/cs/sz → 被污染
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修复方法(三步)
|
||||||
|
|
||||||
|
**Step 1:清除orig runs的污染属性**
|
||||||
|
```python
|
||||||
|
for r in p.findall(f'{WNS}r'): # 只处理原文runs(不在ins/del内的)
|
||||||
|
rpr = r.find(f'{WNS}rPr')
|
||||||
|
rf = rpr.find(f'{WNS}rFonts')
|
||||||
|
if rf is not None:
|
||||||
|
# 如果原文没有eastAsia,strip之
|
||||||
|
if f'{WNS}eastAsia' in rf.attrib:
|
||||||
|
del rf.attrib[f'{WNS}eastAsia']
|
||||||
|
if f'{WNS}cs' in rf.attrib:
|
||||||
|
del rf.attrib[f'{WNS}cs']
|
||||||
|
# 如果原文没有sz(靠继承),strip之
|
||||||
|
sz = rpr.find(f'{WNS}sz')
|
||||||
|
if sz is not None:
|
||||||
|
rpr.remove(sz)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2:设INS runs匹配真实原文**
|
||||||
|
```python
|
||||||
|
for ins in p.findall(f'.//{WNS}ins'):
|
||||||
|
if ins.get(f'{WNS}author') != 'WB': continue
|
||||||
|
for r in ins.findall(f'{WNS}r'):
|
||||||
|
rf = rpr.find(f'{WNS}rFonts')
|
||||||
|
# 设置为原文实际有的属性(如ascii=宋体, hAnsi=宋体)
|
||||||
|
# 不设原文没有的(如eastAsia, cs)
|
||||||
|
# hint按同段orig run的值设
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3:逐段匹配hint**
|
||||||
|
不同段落的orig runs hint状态不同(有的有hint=eastAsia,有的没有)。必须逐段检查并匹配。
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- **不能一刀切**:同一文档不同段落的orig run属性可能不同(P4有hint, P19没hint; P20有完整rFonts, P36完全没rFonts)
|
||||||
|
- **docDefaults是真正的参考基准**:检查`word/styles.xml`的`docDefaults/rPrDefault`了解继承值
|
||||||
|
- **"MISSING HINT (无同段原文可比)"是已知限制**:整段WB INS的新增段落没有orig run对比,脚本报MISSING不是真实错误
|
||||||
|
- **洋励案实证**:120处orig runs被污染,修复后INS只剩14个MISSING HINT(全是新增段落)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Workflow交付件逐份审查检查清单 (2026-07-13 Doro要求)
|
||||||
|
|
||||||
|
## 触发条件
|
||||||
|
Doro说"逐一审查已交付合同的修订有哪些问题"或类似指令。
|
||||||
|
|
||||||
|
## 操作流程
|
||||||
|
1. 全文阅读通用review-rules.md + 对应顾问单位特殊规则
|
||||||
|
2. 列出今天交付的全部文件(`sudo find ... -newermt`)
|
||||||
|
3. 一份一份审查,报告问题,等Doro说pass再做下一份
|
||||||
|
|
||||||
|
## 每份检查项
|
||||||
|
|
||||||
|
### A0. 文件完整性(先于内容)
|
||||||
|
- `zipfile`读`word/comments.xml`看有无批注
|
||||||
|
- 统计`w:ins`/`w:del`数量确认有修订痕迹
|
||||||
|
- 如果有多版本(v1/v2),每个都要独立检查性质
|
||||||
|
|
||||||
|
### A. 格式验证
|
||||||
|
- `wb-ins-font-verify.py` — 必须PASS
|
||||||
|
- 原文同段run属性 vs INS run属性逐一比对
|
||||||
|
- 新增段落pPr(ind/spacing/numPr)vs原文邻近段落
|
||||||
|
|
||||||
|
### B. 审查清单覆盖(10条逐条)
|
||||||
|
1. 主体条款
|
||||||
|
2. 违约责任(含赔偿上限删除、维权费用)
|
||||||
|
3. 争议解决/管辖(甲方所在地法院)
|
||||||
|
4. 保密/数据(归属+存续+泄露赔偿 三要素)
|
||||||
|
5. 知识产权/系统
|
||||||
|
6. 第三方侵权(全责+赔偿甲方损失)
|
||||||
|
7. 转包/分包(限制+连带)
|
||||||
|
8. 价款条款
|
||||||
|
9. 服务成果持续使用权(仅持续性服务适用)
|
||||||
|
10. 条款逻辑
|
||||||
|
|
||||||
|
### C. 同模板一致性
|
||||||
|
- 同批同模板合同的修订是否完全统一
|
||||||
|
- 特别检查:编号顺延方式、章节结构、措辞、天数
|
||||||
|
|
||||||
|
### D. 特殊交付物
|
||||||
|
- 读该顾问单位review-rules.md确认是否要求审查意见文档
|
||||||
|
- 缺失则标记
|
||||||
|
|
||||||
|
### E. 批注审查
|
||||||
|
- 每条WB批注逐一比对规则
|
||||||
|
- 立场是否正确(站甲方)
|
||||||
|
- 是否违反"能改就不批注"
|
||||||
|
- 是否属于提醒性批注(禁止)
|
||||||
|
|
||||||
|
## 报告格式
|
||||||
|
```
|
||||||
|
## 【修】合同名称
|
||||||
|
|
||||||
|
**字体验证:** PASS/FAIL
|
||||||
|
**修订内容:** 逐条列出WB INS/DEL
|
||||||
|
**问题:**
|
||||||
|
1. [严重/一般] 具体问题描述
|
||||||
|
2. ...
|
||||||
|
**结论:** pass建议/需修复
|
||||||
|
```
|
||||||
|
|
||||||
|
### F. 编号顺延完整性(2026-07-13 璞石合同教训)
|
||||||
|
- 章节标题编号顺延后(如七→八),**子编号也必须顺延**(7.1→8.1, 7.2→8.2...)
|
||||||
|
- Workflow常见遗漏:只改了章节标题的汉字编号(第七条→第八条),但内部子条款的阿拉伯数字编号(7.1/7.2/7.3/7.4)原封不动
|
||||||
|
- **检查方法**:accepted text中搜索所有"X.Y"格式编号,确认X与所属章节标题的序号一致
|
||||||
|
- 修复方法:子编号通常拆为两个run(如"7" + ".1 "),只需DEL第一个run("7")+INS新数字("8")
|
||||||
|
|
||||||
|
### G. 内容去重(2026-07-13 璞石合同教训)
|
||||||
|
- 新增的保密存续条款是否与原文已有的类似表述重复
|
||||||
|
- 典型:原文已有"乙方的保密义务不因合同解除或终止而免除",WB又插入"本条保密义务不因本合同的终止或解除而终止"——语义完全重复
|
||||||
|
|
||||||
|
### H. 赔偿上限全面检查(2026-07-13 璞石合同教训)
|
||||||
|
- 规则"赔偿上限能删就删"不仅适用于乙方赔偿甲方的上限
|
||||||
|
- **双向条款中的上限也要删**:如"任何一方违约,违约金额为合同总金额的20%"——此上限同时限制了甲方可获赔偿
|
||||||
|
- P43甲方自身违约金上限保留是正确的(保护甲方),但P49双向上限应删除
|
||||||
|
- **判断方法**:上限是否限制了对方向甲方赔偿?是→删;上限是否限制了甲方向对方赔偿?是→保留
|
||||||
|
|
||||||
|
### I. 新增标题段落样式(2026-07-13 璞石合同教训)
|
||||||
|
- WB新增的章节标题段(如"第七条 转包与分包")的pStyle必须与原文标题段一致
|
||||||
|
- 原文标题用Heading4→新增也用Heading4,不能用Style15(正文首行缩进)
|
||||||
|
- 标题文字中"第X条"与名称之间是否有空格?原文无空格("第六条违约责任")则新增也不加空格
|
||||||
|
|
||||||
|
### J. 原文已有修订保持不动
|
||||||
|
- 对照原文确认:WB-1/86187/杨丽/富强等原文修订人的INS/DEL/批注是否全部原样保留
|
||||||
|
- WB的修改不能意外覆盖或嵌套进原文修订
|
||||||
|
- P29金额"27900"的sz=22是原文86187的修订→不是workflow问题
|
||||||
|
|
||||||
|
## 铁律
|
||||||
|
- 先tool call读文件再下结论(验证指令铁律)
|
||||||
|
- 不凭上一份的印象判断下一份
|
||||||
|
- comments.xml必须检查(2026-07-13教训)
|
||||||
|
- **原文自带【修】前缀的文件**:按命名规则应为【修】【修】...,workflow通常不做双重前缀——记录为已知缺陷
|
||||||
|
- **Doro说"看清楚前后文再回复"**:意思是你漏了问题或误判了严重性,必须重新逐属性检查
|
||||||
Binary file not shown.
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""生成"接受所有修订后"的干净 docx,用于 OnlyOffice 渲染做字体/排版的决定性视觉验证。
|
||||||
|
|
||||||
|
为什么需要:OnlyOffice 渲染修订态文字(w:ins,紫色+下划线)时视觉上常显示为类无衬线、
|
||||||
|
看起来字体/粗细与正文不同——这是 track-changes 的渲染特性,不是真实字体差异。vision 工具
|
||||||
|
会据此误报"字体不一致",导致无谓返工。把所有修订接受、批注去掉后再渲染,才能在无修订
|
||||||
|
颜色干扰下看到插入文字与正文的真实字体一致性。
|
||||||
|
|
||||||
|
用法: python accept-revisions-preview.py <in.docx> <out.docx>
|
||||||
|
处理: 解包所有 w:ins(保留内容)+ 删除所有 w:del(连内容)+ 移除批注锚点标记。
|
||||||
|
注意: 产物仅供"渲染核对",不是正式交付物(交付的是带修订痕迹的版本)。
|
||||||
|
"""
|
||||||
|
import sys, zipfile, io
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||||
|
Wq = '{' + W + '}'
|
||||||
|
|
||||||
|
|
||||||
|
def accept_revisions(in_path, out_path):
|
||||||
|
with open(in_path, 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
bin_, bout = io.BytesIO(data), io.BytesIO()
|
||||||
|
with zipfile.ZipFile(bin_) as zin, zipfile.ZipFile(bout, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for item in zin.infolist():
|
||||||
|
raw = zin.read(item.filename)
|
||||||
|
if item.filename == 'word/document.xml':
|
||||||
|
root = etree.fromstring(raw)
|
||||||
|
# 删除所有 w:del(含内容)
|
||||||
|
for d in [e for e in root.iter(Wq + 'del')]:
|
||||||
|
d.getparent().remove(d)
|
||||||
|
# 解包所有 w:ins:把子元素提到 ins 的位置后删除 ins 壳
|
||||||
|
for ins in [e for e in root.iter(Wq + 'ins')]:
|
||||||
|
parent = ins.getparent()
|
||||||
|
idx = list(parent).index(ins)
|
||||||
|
for child in reversed(list(ins)):
|
||||||
|
parent.insert(idx, child)
|
||||||
|
parent.remove(ins)
|
||||||
|
# 移除批注锚点标记
|
||||||
|
for tag in ('commentRangeStart', 'commentRangeEnd'):
|
||||||
|
for e in [x for x in root.iter(Wq + tag)]:
|
||||||
|
e.getparent().remove(e)
|
||||||
|
for r in [x for x in root.iter(Wq + 'r')]:
|
||||||
|
if r.find(Wq + 'commentReference') is not None:
|
||||||
|
r.getparent().remove(r)
|
||||||
|
raw = etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||||
|
zout.writestr(item, raw)
|
||||||
|
with open(out_path, 'wb') as f:
|
||||||
|
f.write(bout.getvalue())
|
||||||
|
print(f'接受修订版已生成: {out_path}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print('用法: python accept-revisions-preview.py <in.docx> <out.docx>')
|
||||||
|
sys.exit(1)
|
||||||
|
accept_revisions(sys.argv[1], sys.argv[2])
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
contract_docx_lib.py — 合同修订核心库
|
||||||
|
固化验证通过的docx XML操作,不再每次重写。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
from contract_docx_lib import ContractEditor
|
||||||
|
|
||||||
|
editor = ContractEditor("原文件.docx")
|
||||||
|
editor.tracked_replace("原文片段", "新文片段")
|
||||||
|
editor.add_clause("19.服务成果持续使用权", "条款内容...", after_clause=18)
|
||||||
|
editor.renumber(19, 20) # 原19→20
|
||||||
|
errors = editor.validate()
|
||||||
|
if not errors:
|
||||||
|
editor.save("【修】原文件.docx")
|
||||||
|
|
||||||
|
关键操作顺序(renumber和新增条款):
|
||||||
|
1. 先做所有 tracked_replace(文本修改)
|
||||||
|
2. 再做 add_clause(新增子条款,如15.4)
|
||||||
|
3. 再做 renumber_range(先腾出编号空间)
|
||||||
|
4. 最后做 add_clause_before(插入新主条款,用已腾出的编号)
|
||||||
|
5. validate() 验证
|
||||||
|
6. save() 保存
|
||||||
|
"""
|
||||||
|
|
||||||
|
import zipfile, io, copy, re, difflib
|
||||||
|
from lxml import etree
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||||
|
WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'
|
||||||
|
XML_SPACE = '{http://www.w3.org/XML/1998/namespace}space'
|
||||||
|
WNS = '{' + W + '}'
|
||||||
|
|
||||||
|
def qn(tag):
|
||||||
|
return f'{WNS}{tag}'
|
||||||
|
|
||||||
|
|
||||||
|
def cjk_tokenize(text):
|
||||||
|
"""CJK每字一token,ASCII连续一token,标点单独token。
|
||||||
|
经验证的分词策略,不要改。"""
|
||||||
|
tokens = []
|
||||||
|
i = 0
|
||||||
|
while i < len(text):
|
||||||
|
ch = text[i]
|
||||||
|
if '\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f' or ch in ',。、;:!?""''()【】《》—…·[]%%':
|
||||||
|
tokens.append(ch)
|
||||||
|
i += 1
|
||||||
|
elif ch.isascii() and ch.isalnum():
|
||||||
|
j = i
|
||||||
|
while j < len(text) and text[j].isascii() and text[j].isalnum():
|
||||||
|
j += 1
|
||||||
|
tokens.append(text[i:j])
|
||||||
|
i = j
|
||||||
|
else:
|
||||||
|
tokens.append(ch)
|
||||||
|
i += 1
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
class ContractEditor:
|
||||||
|
"""合同修订编辑器。一个实例对应一份合同文件。"""
|
||||||
|
|
||||||
|
def __init__(self, filepath):
|
||||||
|
self.filepath = Path(filepath)
|
||||||
|
with open(filepath, 'rb') as f:
|
||||||
|
self.original_bytes = f.read()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
|
||||||
|
self.doc_xml = z.read('word/document.xml')
|
||||||
|
|
||||||
|
self.tree = etree.fromstring(self.doc_xml)
|
||||||
|
self.body = self.tree.find(qn('body'))
|
||||||
|
self._rev_id = 100
|
||||||
|
self._revision_date = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
self._author = 'WB'
|
||||||
|
self._rsid = '00AA0001'
|
||||||
|
|
||||||
|
# 提取原文格式(核心:避免每次猜错格式)
|
||||||
|
self._body_rpr = None # 正文格式(最常见的非加粗rPr)
|
||||||
|
self._title_rpr = None # 条款标题格式(加粗的rPr)
|
||||||
|
self._body_ppr = None
|
||||||
|
self._extract_formats()
|
||||||
|
|
||||||
|
def _extract_formats(self):
|
||||||
|
"""从原文提取正文和标题的rPr。
|
||||||
|
策略:
|
||||||
|
- 正文格式:统计所有run的rPr,取出现最多的非加粗rPr
|
||||||
|
- 标题格式:优先从条款编号标题段落(如"7.索赔条款")提取rPr,
|
||||||
|
而非简单取第一个加粗run(可能是合同大标题,字号不同)
|
||||||
|
- 如果条款标题不加粗,标题格式回退到正文格式"""
|
||||||
|
import re
|
||||||
|
rpr_map = {} # serialized_rpr -> (count, rpr_element)
|
||||||
|
clause_title_rpr = None # 从条款编号标题提取的格式
|
||||||
|
first_bold_rpr = None # 第一个加粗run的格式(fallback)
|
||||||
|
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
# 获取段落全文,判断是否是条款编号标题(如 "7.索赔条款" "5.伴随服务")
|
||||||
|
p_text = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}')).strip()
|
||||||
|
is_clause_title = bool(re.match(r'^\d+[..、]\s*\S', p_text)) and len(p_text) < 30
|
||||||
|
|
||||||
|
for r in p.findall(qn('r')):
|
||||||
|
rpr = r.find(qn('rPr'))
|
||||||
|
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||||
|
if not txt.strip() or len(txt) < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if rpr is not None:
|
||||||
|
is_bold = rpr.find(qn('b')) is not None
|
||||||
|
key = etree.tostring(rpr, encoding='unicode')
|
||||||
|
|
||||||
|
if is_bold and first_bold_rpr is None:
|
||||||
|
first_bold_rpr = rpr
|
||||||
|
|
||||||
|
# 优先从条款标题段落提取标题格式
|
||||||
|
if is_clause_title and clause_title_rpr is None:
|
||||||
|
clause_title_rpr = rpr
|
||||||
|
|
||||||
|
if not is_bold:
|
||||||
|
if key not in rpr_map:
|
||||||
|
rpr_map[key] = [0, rpr]
|
||||||
|
rpr_map[key][0] += 1
|
||||||
|
|
||||||
|
if self._body_ppr is None:
|
||||||
|
ppr = p.find(qn('pPr'))
|
||||||
|
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
|
||||||
|
if ppr is not None and len(txt) > 20:
|
||||||
|
self._body_ppr = ppr
|
||||||
|
|
||||||
|
if rpr_map:
|
||||||
|
best = max(rpr_map.values(), key=lambda x: x[0])
|
||||||
|
self._body_rpr = best[1]
|
||||||
|
|
||||||
|
# 标题格式优先级:条款编号标题 > 第一个加粗run > 正文格式
|
||||||
|
self._title_rpr = clause_title_rpr or first_bold_rpr or self._body_rpr
|
||||||
|
|
||||||
|
if self._title_rpr is None and self._body_rpr is not None:
|
||||||
|
self._title_rpr = copy.deepcopy(self._body_rpr)
|
||||||
|
etree.SubElement(self._title_rpr, qn('b'))
|
||||||
|
|
||||||
|
def _next_id(self):
|
||||||
|
self._rev_id += 1
|
||||||
|
return str(self._rev_id)
|
||||||
|
|
||||||
|
def _mk_del(self, text, rpr=None):
|
||||||
|
d = etree.Element(qn('del'))
|
||||||
|
d.set(qn('id'), self._next_id())
|
||||||
|
d.set(qn('author'), self._author)
|
||||||
|
d.set(qn('date'), self._revision_date)
|
||||||
|
r = etree.SubElement(d, qn('r'))
|
||||||
|
r.set(qn('rsidDel'), self._rsid)
|
||||||
|
if rpr is not None:
|
||||||
|
r.append(copy.deepcopy(rpr))
|
||||||
|
t = etree.SubElement(r, qn('delText'))
|
||||||
|
t.set(XML_SPACE, 'preserve')
|
||||||
|
t.text = text
|
||||||
|
return d
|
||||||
|
|
||||||
|
def _mk_ins(self, text, rpr=None):
|
||||||
|
i = etree.Element(qn('ins'))
|
||||||
|
i.set(qn('id'), self._next_id())
|
||||||
|
i.set(qn('author'), self._author)
|
||||||
|
i.set(qn('date'), self._revision_date)
|
||||||
|
r = etree.SubElement(i, qn('r'))
|
||||||
|
r.set(qn('rsidR'), self._rsid)
|
||||||
|
if rpr is not None:
|
||||||
|
r.append(copy.deepcopy(rpr))
|
||||||
|
t = etree.SubElement(r, qn('t'))
|
||||||
|
t.set(XML_SPACE, 'preserve')
|
||||||
|
t.text = text
|
||||||
|
return i
|
||||||
|
|
||||||
|
def _mk_run(self, text, rpr=None):
|
||||||
|
r = etree.Element(qn('r'))
|
||||||
|
if rpr is not None:
|
||||||
|
r.append(copy.deepcopy(rpr))
|
||||||
|
t = etree.SubElement(r, qn('t'))
|
||||||
|
t.set(XML_SPACE, 'preserve')
|
||||||
|
t.text = text
|
||||||
|
return r
|
||||||
|
|
||||||
|
def get_para_text(self, p):
|
||||||
|
"""获取段落的原始文本(不含删除标记中的文本)"""
|
||||||
|
return ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
|
||||||
|
|
||||||
|
def find_para(self, search_text):
|
||||||
|
"""查找包含指定文本的段落"""
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
if search_text in self.get_para_text(p):
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
def tracked_replace(self, old_text, new_text):
|
||||||
|
"""在整个文档中查找old_text并用修订模式替换为new_text。
|
||||||
|
使用字符级tokenizer+difflib实现精准修订。
|
||||||
|
返回True如果成功。"""
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
runs = p.findall(f'.//{qn("r")}')
|
||||||
|
if not runs:
|
||||||
|
continue
|
||||||
|
full = ''.join(
|
||||||
|
''.join(t.text or '' for t in r.findall(qn('t')))
|
||||||
|
for r in runs
|
||||||
|
)
|
||||||
|
if old_text not in full:
|
||||||
|
continue
|
||||||
|
|
||||||
|
start = full.index(old_text)
|
||||||
|
end = start + len(old_text)
|
||||||
|
|
||||||
|
# 获取匹配位置的rPr
|
||||||
|
rpr = None
|
||||||
|
pos = 0
|
||||||
|
for r in runs:
|
||||||
|
rt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||||
|
if pos + len(rt) > start:
|
||||||
|
rpr = r.find(qn('rPr'))
|
||||||
|
break
|
||||||
|
pos += len(rt)
|
||||||
|
|
||||||
|
# 生成diff元素
|
||||||
|
if new_text == '':
|
||||||
|
elems = [self._mk_del(old_text, rpr)]
|
||||||
|
else:
|
||||||
|
ot = cjk_tokenize(old_text)
|
||||||
|
nt = cjk_tokenize(new_text)
|
||||||
|
matcher = difflib.SequenceMatcher(None, ot, nt)
|
||||||
|
elems = []
|
||||||
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
|
if tag == 'equal':
|
||||||
|
elems.append(self._mk_run(''.join(ot[i1:i2]), rpr))
|
||||||
|
elif tag == 'delete':
|
||||||
|
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
|
||||||
|
elif tag == 'insert':
|
||||||
|
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
|
||||||
|
elif tag == 'replace':
|
||||||
|
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
|
||||||
|
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
|
||||||
|
|
||||||
|
# 定位受影响的runs并替换
|
||||||
|
pos = 0
|
||||||
|
first = last = None
|
||||||
|
prefix_text = suffix_text = ""
|
||||||
|
for idx, r in enumerate(runs):
|
||||||
|
rt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||||
|
run_end = pos + len(rt)
|
||||||
|
if run_end > start and pos < end:
|
||||||
|
if first is None:
|
||||||
|
first = idx
|
||||||
|
prefix_text = full[pos:start]
|
||||||
|
last = idx
|
||||||
|
suffix_text = full[end:run_end] if run_end > end else ""
|
||||||
|
pos = run_end
|
||||||
|
|
||||||
|
if first is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ref = runs[first]
|
||||||
|
# Find the actual paragraph (w:p) element to insert into
|
||||||
|
para_elem = p
|
||||||
|
# Determine insert position: find ref or its ancestor that is a direct child of p
|
||||||
|
ref_ancestor = ref
|
||||||
|
while ref_ancestor.getparent() is not para_elem and ref_ancestor.getparent() is not None:
|
||||||
|
ref_ancestor = ref_ancestor.getparent()
|
||||||
|
insert_pos = list(para_elem).index(ref_ancestor)
|
||||||
|
|
||||||
|
# Remove runs (each from its own parent)
|
||||||
|
for idx in range(last, first - 1, -1):
|
||||||
|
r = runs[idx]
|
||||||
|
r_parent = r.getparent()
|
||||||
|
r_parent.remove(r)
|
||||||
|
# If parent (e.g. w:ins) is now empty, remove it too
|
||||||
|
if r_parent is not para_elem and len(r_parent) == 0:
|
||||||
|
gp = r_parent.getparent()
|
||||||
|
if gp is not None:
|
||||||
|
gp.remove(r_parent)
|
||||||
|
|
||||||
|
ip = insert_pos
|
||||||
|
if prefix_text:
|
||||||
|
para_elem.insert(ip, self._mk_run(prefix_text, rpr))
|
||||||
|
ip += 1
|
||||||
|
for e in elems:
|
||||||
|
para_elem.insert(ip, e)
|
||||||
|
ip += 1
|
||||||
|
if suffix_text:
|
||||||
|
para_elem.insert(ip, self._mk_run(suffix_text, rpr))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_leading_whitespace(self, para):
|
||||||
|
"""从段落中提取前导空格/tab模式。
|
||||||
|
很多中文文档的缩进不是通过w:ind实现的,而是通过文本中的空格字符。"""
|
||||||
|
for r in para.findall(qn('r')):
|
||||||
|
# Skip deleted runs
|
||||||
|
if r.getparent().tag == qn('del'):
|
||||||
|
continue
|
||||||
|
for t in r.findall(qn('t')):
|
||||||
|
if t.text:
|
||||||
|
# Extract leading whitespace
|
||||||
|
stripped = t.text.lstrip()
|
||||||
|
if stripped: # Has actual content after whitespace
|
||||||
|
return t.text[:len(t.text) - len(stripped)]
|
||||||
|
elif t.text.isspace(): # Entire run is whitespace
|
||||||
|
return t.text
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def add_clause(self, full_text, after_search, use_title_format=False):
|
||||||
|
"""在包含after_search的段落之后插入新条款段落。
|
||||||
|
|
||||||
|
full_text: 新条款全文
|
||||||
|
after_search: 在包含此文本的段落之后插入
|
||||||
|
use_title_format: True=标题格式(加粗),False=正文格式
|
||||||
|
"""
|
||||||
|
ref_para = self.find_para(after_search)
|
||||||
|
if ref_para is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
rpr = self._title_rpr if use_title_format else self._body_rpr
|
||||||
|
ppr = ref_para.find(qn('pPr')) or self._body_ppr
|
||||||
|
|
||||||
|
# 复制相邻段落的前导空格模式
|
||||||
|
leading_ws = self._get_leading_whitespace(ref_para)
|
||||||
|
if leading_ws and not full_text.startswith(leading_ws):
|
||||||
|
full_text = leading_ws + full_text
|
||||||
|
|
||||||
|
new_p = etree.Element(qn('p'))
|
||||||
|
if ppr is not None:
|
||||||
|
new_p.append(copy.deepcopy(ppr))
|
||||||
|
new_p.append(self._mk_ins(full_text, rpr))
|
||||||
|
|
||||||
|
idx = list(self.body).index(ref_para)
|
||||||
|
self.body.insert(idx + 1, new_p)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def add_clause_before(self, full_text, before_search, use_title_format=False):
|
||||||
|
"""在包含before_search的段落之前插入新条款段落。"""
|
||||||
|
ref_para = self.find_para(before_search)
|
||||||
|
if ref_para is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
rpr = self._title_rpr if use_title_format else self._body_rpr
|
||||||
|
ppr = ref_para.find(qn('pPr')) or self._body_ppr
|
||||||
|
|
||||||
|
# 复制相邻段落的前导空格模式
|
||||||
|
leading_ws = self._get_leading_whitespace(ref_para)
|
||||||
|
if leading_ws and not full_text.startswith(leading_ws):
|
||||||
|
full_text = leading_ws + full_text
|
||||||
|
|
||||||
|
new_p = etree.Element(qn('p'))
|
||||||
|
if ppr is not None:
|
||||||
|
new_p.append(copy.deepcopy(ppr))
|
||||||
|
new_p.append(self._mk_ins(full_text, rpr))
|
||||||
|
|
||||||
|
idx = list(self.body).index(ref_para)
|
||||||
|
self.body.insert(idx, new_p)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def add_mixed_clause(self, title_text, content_text, after_search):
|
||||||
|
"""插入标题加粗+内容不加粗的新条款(两个段落)。
|
||||||
|
用于原文标题和内容分行的合同格式。"""
|
||||||
|
ref_para = self.find_para(after_search)
|
||||||
|
if ref_para is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ppr = ref_para.find(qn('pPr')) or self._body_ppr
|
||||||
|
idx = list(self.body).index(ref_para)
|
||||||
|
|
||||||
|
# 复制相邻段落的前导空格模式
|
||||||
|
leading_ws = self._get_leading_whitespace(ref_para)
|
||||||
|
if leading_ws:
|
||||||
|
if not title_text.startswith(leading_ws):
|
||||||
|
title_text = leading_ws + title_text
|
||||||
|
if not content_text.startswith(leading_ws):
|
||||||
|
content_text = leading_ws + content_text
|
||||||
|
|
||||||
|
p_title = etree.Element(qn('p'))
|
||||||
|
if ppr: p_title.append(copy.deepcopy(ppr))
|
||||||
|
p_title.append(self._mk_ins(title_text, self._title_rpr))
|
||||||
|
self.body.insert(idx + 1, p_title)
|
||||||
|
|
||||||
|
p_content = etree.Element(qn('p'))
|
||||||
|
if ppr: p_content.append(copy.deepcopy(ppr))
|
||||||
|
p_content.append(self._mk_ins(content_text, self._body_rpr))
|
||||||
|
self.body.insert(idx + 2, p_content)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def renumber_clause(self, old_num, new_num):
|
||||||
|
"""把条款编号从old_num改为new_num(修订模式)。
|
||||||
|
从后往前扫描,避免重复修改。"""
|
||||||
|
changed = 0
|
||||||
|
for p in reversed(self.body.findall(qn('p'))):
|
||||||
|
runs = p.findall(f'.//{qn("r")}')
|
||||||
|
for r in runs:
|
||||||
|
for t in r.findall(qn('t')):
|
||||||
|
if t.text and old_num in t.text:
|
||||||
|
rpr_e = r.find(qn('rPr'))
|
||||||
|
parent = r.getparent()
|
||||||
|
idx_r = list(parent).index(r)
|
||||||
|
|
||||||
|
pos = t.text.index(old_num)
|
||||||
|
prefix = t.text[:pos]
|
||||||
|
suffix = t.text[pos + len(old_num):]
|
||||||
|
|
||||||
|
parent.remove(r)
|
||||||
|
ip = idx_r
|
||||||
|
if prefix:
|
||||||
|
parent.insert(ip, self._mk_run(prefix, rpr_e))
|
||||||
|
ip += 1
|
||||||
|
parent.insert(ip, self._mk_del(old_num, rpr_e))
|
||||||
|
ip += 1
|
||||||
|
parent.insert(ip, self._mk_ins(new_num, rpr_e))
|
||||||
|
ip += 1
|
||||||
|
if suffix:
|
||||||
|
parent.insert(ip, self._mk_run(suffix, rpr_e))
|
||||||
|
|
||||||
|
changed += 1
|
||||||
|
break
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def renumber_range(self, start, shift=1):
|
||||||
|
"""从start开始,所有现有条款编号+shift。从后往前处理。
|
||||||
|
|
||||||
|
注意:先调用此方法腾出编号空间,再插入新条款。
|
||||||
|
例:要在18后插入新19条:
|
||||||
|
editor.renumber_range(19, 1) # 19→20, 20→21, 21→22
|
||||||
|
editor.add_clause_before("19.新条款内容", before_search="20.合同生效")
|
||||||
|
"""
|
||||||
|
max_num = 0
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
txt = self.get_para_text(p)
|
||||||
|
for m in re.finditer(r'(\d+)[..]', txt):
|
||||||
|
n = int(m.group(1))
|
||||||
|
if n > max_num:
|
||||||
|
max_num = n
|
||||||
|
|
||||||
|
for n in range(max_num, start - 1, -1):
|
||||||
|
self.renumber_clause(f'{n}.', f'{n + shift}.')
|
||||||
|
self.renumber_clause(f'{n}.', f'{n + shift}.')
|
||||||
|
|
||||||
|
def renumber_chinese(self, old_cn, new_cn):
|
||||||
|
"""中文编号顺延,如 "第十三条" → "第十四条"。"""
|
||||||
|
return self.renumber_clause(old_cn, new_cn)
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
"""交付前验证。返回错误列表,空列表=通过。"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# 1. 编号连续性
|
||||||
|
clause_nums = []
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
accepted = ''
|
||||||
|
for child in p:
|
||||||
|
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||||
|
if tag == 'r':
|
||||||
|
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
|
||||||
|
elif tag == 'ins':
|
||||||
|
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
|
||||||
|
m = re.match(r'^(\d+)[..]', accepted.strip())
|
||||||
|
if m:
|
||||||
|
clause_nums.append(int(m.group(1)))
|
||||||
|
|
||||||
|
main_clauses = sorted(set(clause_nums))
|
||||||
|
for i in range(1, len(main_clauses)):
|
||||||
|
if main_clauses[i] - main_clauses[i-1] > 1:
|
||||||
|
errors.append(f"编号跳跃: {main_clauses[i-1]}→{main_clauses[i]},缺少{main_clauses[i-1]+1}")
|
||||||
|
|
||||||
|
# 2. 字号一致性(WB的ins内容 vs 原文正文)
|
||||||
|
if self._body_rpr is not None:
|
||||||
|
body_sz = None
|
||||||
|
sz_elem = self._body_rpr.find(qn('sz'))
|
||||||
|
if sz_elem is not None:
|
||||||
|
body_sz = sz_elem.get(qn('val'))
|
||||||
|
|
||||||
|
if body_sz:
|
||||||
|
for ins in self.tree.findall(f'.//{qn("ins")}'):
|
||||||
|
if ins.get(qn('author')) != self._author:
|
||||||
|
continue
|
||||||
|
for r in ins.findall(qn('r')):
|
||||||
|
rpr = r.find(qn('rPr'))
|
||||||
|
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||||
|
if not txt.strip():
|
||||||
|
continue
|
||||||
|
if rpr is not None:
|
||||||
|
ins_sz = rpr.find(qn('sz'))
|
||||||
|
if ins_sz is not None:
|
||||||
|
val = ins_sz.get(qn('val'))
|
||||||
|
is_bold = rpr.find(qn('b')) is not None
|
||||||
|
if val != body_sz and not is_bold:
|
||||||
|
errors.append(f"字号不一致: ins sz={val} vs 原文sz={body_sz},'{txt[:30]}'")
|
||||||
|
|
||||||
|
# 3. 加粗规则(内容不应加粗)
|
||||||
|
for ins in self.tree.findall(f'.//{qn("ins")}'):
|
||||||
|
if ins.get(qn('author')) != self._author:
|
||||||
|
continue
|
||||||
|
for r in ins.findall(qn('r')):
|
||||||
|
rpr = r.find(qn('rPr'))
|
||||||
|
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||||
|
if not txt.strip() or len(txt.strip()) < 5:
|
||||||
|
continue
|
||||||
|
is_bold = rpr is not None and rpr.find(qn('b')) is not None
|
||||||
|
is_clause_title = bool(re.match(r'^\d+[..]\S', txt.strip())) or bool(re.match(r'^第.{1,3}条', txt.strip())) or bool(re.match(r'^[一二三四五六七八九十]{1,3}、', txt.strip()))
|
||||||
|
if is_bold and not is_clause_title:
|
||||||
|
errors.append(f"不应加粗: '{txt[:40]}'")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def dump_numbering(self):
|
||||||
|
"""输出accepted view的编号序列,用于人工确认"""
|
||||||
|
result = []
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
accepted = ''
|
||||||
|
for child in p:
|
||||||
|
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||||
|
if tag == 'r':
|
||||||
|
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
|
||||||
|
elif tag == 'ins':
|
||||||
|
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
|
||||||
|
m = re.match(r'^(\d+)[..]', accepted.strip())
|
||||||
|
if m:
|
||||||
|
result.append(f"{m.group(1)}. {accepted.strip()[:60]}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def save(self, output_path):
|
||||||
|
"""保存修订后的文件"""
|
||||||
|
new_doc_xml = etree.tostring(self.tree, xml_declaration=True,
|
||||||
|
encoding='UTF-8', standalone=True)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
|
||||||
|
settings = z.read('word/settings.xml')
|
||||||
|
stree = etree.fromstring(settings)
|
||||||
|
if stree.find(f'.//{qn("trackRevisions")}') is None:
|
||||||
|
stree.append(etree.Element(qn('trackRevisions')))
|
||||||
|
new_settings = etree.tostring(stree, xml_declaration=True,
|
||||||
|
encoding='UTF-8', standalone=True)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as zin:
|
||||||
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for item in zin.infolist():
|
||||||
|
if item.filename == 'word/document.xml':
|
||||||
|
zout.writestr(item, new_doc_xml)
|
||||||
|
elif item.filename == 'word/settings.xml':
|
||||||
|
zout.writestr(item, new_settings)
|
||||||
|
else:
|
||||||
|
zout.writestr(item, zin.read(item.filename))
|
||||||
|
|
||||||
|
with open(output_path, 'wb') as f:
|
||||||
|
f.write(buf.getvalue())
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
class ZhujiajaoOpinion:
|
||||||
|
"""朱家角审查意见表格填写器。严格使用模板结构,不自创格式。"""
|
||||||
|
|
||||||
|
TEMPLATE_PATH = Path.home() / ".hermes/shared/模版库/朱家角 审查意见【模板】.docx"
|
||||||
|
|
||||||
|
def __init__(self, template_path=None):
|
||||||
|
tpath = Path(template_path) if template_path else self.TEMPLATE_PATH
|
||||||
|
with open(tpath, 'rb') as f:
|
||||||
|
self.tmpl_bytes = f.read()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as z:
|
||||||
|
self.doc_xml = z.read('word/document.xml')
|
||||||
|
|
||||||
|
self.tree = etree.fromstring(self.doc_xml)
|
||||||
|
self.body = self.tree.find(qn('body'))
|
||||||
|
|
||||||
|
def fill(self, contract_name, items, has_modifications=True):
|
||||||
|
"""填写审查意见。
|
||||||
|
|
||||||
|
contract_name: 合同名称(填入标题《》中间)
|
||||||
|
items: [(条文位置, 原文, 修订后), ...]
|
||||||
|
has_modifications: False则保留"无法律修改意见"
|
||||||
|
"""
|
||||||
|
# 1. 填标题——找到空格run替换
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
runs = p.findall(f'.//{qn("r")}')
|
||||||
|
for r in runs:
|
||||||
|
for t in r.findall(qn('t')):
|
||||||
|
if t.text and t.text.strip() == '' and len(t.text) >= 2:
|
||||||
|
parent_txt = ''.join(
|
||||||
|
tt.text or '' for rr in runs for tt in rr.findall(qn('t'))
|
||||||
|
)
|
||||||
|
if '关于《' in parent_txt:
|
||||||
|
t.text = contract_name
|
||||||
|
|
||||||
|
# 2. 处理"无法律修改意见"
|
||||||
|
if has_modifications:
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
|
||||||
|
if '无法律修改意见' in txt:
|
||||||
|
for r in p.findall(f'.//{qn("r")}'):
|
||||||
|
for t in r.findall(qn('t')):
|
||||||
|
if '无法律修改意见' in (t.text or ''):
|
||||||
|
t.text = ''
|
||||||
|
|
||||||
|
# 3. 填表格
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
|
||||||
|
tbl = self.body.find(qn('tbl'))
|
||||||
|
if tbl is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = tbl.findall(qn('tr'))
|
||||||
|
# Row 0 = header, Row 1+ = data rows
|
||||||
|
|
||||||
|
# 获取表头rPr
|
||||||
|
header_rpr = None
|
||||||
|
for hc in rows[0].findall(qn('tc')):
|
||||||
|
for hr in hc.findall(f'.//{qn("r")}'):
|
||||||
|
rr = hr.find(qn('rPr'))
|
||||||
|
if rr:
|
||||||
|
header_rpr = rr
|
||||||
|
break
|
||||||
|
if header_rpr:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 确保有足够数据行
|
||||||
|
template_row = rows[1] if len(rows) > 1 else None
|
||||||
|
while len(tbl.findall(qn('tr'))) - 1 < len(items):
|
||||||
|
if template_row is not None:
|
||||||
|
tbl.append(copy.deepcopy(template_row))
|
||||||
|
|
||||||
|
rows = tbl.findall(qn('tr'))
|
||||||
|
|
||||||
|
# 填写数据
|
||||||
|
for i, (clause, orig_text, modified_text) in enumerate(items):
|
||||||
|
if i + 1 >= len(rows):
|
||||||
|
break
|
||||||
|
row = rows[i + 1]
|
||||||
|
cells = row.findall(qn('tc'))
|
||||||
|
if len(cells) < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for ci, text in enumerate([clause, orig_text, modified_text]):
|
||||||
|
cell = cells[ci]
|
||||||
|
p = cell.find(qn('p'))
|
||||||
|
if p is None:
|
||||||
|
p = etree.SubElement(cell, qn('p'))
|
||||||
|
for r in p.findall(qn('r')):
|
||||||
|
p.remove(r)
|
||||||
|
|
||||||
|
r = etree.SubElement(p, qn('r'))
|
||||||
|
if header_rpr:
|
||||||
|
new_rpr = copy.deepcopy(header_rpr)
|
||||||
|
b = new_rpr.find(qn('b'))
|
||||||
|
if b is not None:
|
||||||
|
new_rpr.remove(b)
|
||||||
|
if '注:' in text:
|
||||||
|
color = new_rpr.find(qn('color'))
|
||||||
|
if color is None:
|
||||||
|
color = etree.SubElement(new_rpr, qn('color'))
|
||||||
|
color.set(qn('val'), 'FF0000')
|
||||||
|
r.append(new_rpr)
|
||||||
|
|
||||||
|
t = etree.SubElement(r, qn('t'))
|
||||||
|
t.set(XML_SPACE, 'preserve')
|
||||||
|
t.text = text
|
||||||
|
|
||||||
|
# 删除多余空行
|
||||||
|
rows = tbl.findall(qn('tr'))
|
||||||
|
for i in range(len(rows) - 1, len(items), -1):
|
||||||
|
tbl.remove(rows[i])
|
||||||
|
|
||||||
|
def save(self, output_path):
|
||||||
|
new_doc = etree.tostring(self.tree, xml_declaration=True,
|
||||||
|
encoding='UTF-8', standalone=True)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as zin:
|
||||||
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for item in zin.infolist():
|
||||||
|
if item.filename == 'word/document.xml':
|
||||||
|
zout.writestr(item, new_doc)
|
||||||
|
else:
|
||||||
|
zout.writestr(item, zin.read(item.filename))
|
||||||
|
with open(output_path, 'wb') as f:
|
||||||
|
f.write(buf.getvalue())
|
||||||
|
return output_path
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
contract_docx_lib.py — 合同修订核心库
|
||||||
|
固化验证通过的docx XML操作,不再每次重写。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
from contract_docx_lib import ContractEditor
|
||||||
|
|
||||||
|
editor = ContractEditor("原文件.docx")
|
||||||
|
editor.tracked_replace("原文片段", "新文片段")
|
||||||
|
editor.add_clause("19.服务成果持续使用权", "条款内容...", after_clause=18)
|
||||||
|
editor.renumber(19, 20) # 原19→20
|
||||||
|
errors = editor.validate()
|
||||||
|
if not errors:
|
||||||
|
editor.save("【修】原文件.docx")
|
||||||
|
|
||||||
|
关键操作顺序(renumber和新增条款):
|
||||||
|
1. 先做所有 tracked_replace(文本修改)
|
||||||
|
2. 再做 add_clause(新增子条款,如15.4)
|
||||||
|
3. 再做 renumber_range(先腾出编号空间)
|
||||||
|
4. 最后做 add_clause_before(插入新主条款,用已腾出的编号)
|
||||||
|
5. validate() 验证
|
||||||
|
6. save() 保存
|
||||||
|
"""
|
||||||
|
|
||||||
|
import zipfile, io, copy, re, difflib
|
||||||
|
from lxml import etree
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||||
|
WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'
|
||||||
|
XML_SPACE = '{http://www.w3.org/XML/1998/namespace}space'
|
||||||
|
WNS = '{' + W + '}'
|
||||||
|
|
||||||
|
def qn(tag):
|
||||||
|
return f'{WNS}{tag}'
|
||||||
|
|
||||||
|
|
||||||
|
def cjk_tokenize(text):
|
||||||
|
"""CJK每字一token,ASCII连续一token,标点单独token。
|
||||||
|
经验证的分词策略,不要改。"""
|
||||||
|
tokens = []
|
||||||
|
i = 0
|
||||||
|
while i < len(text):
|
||||||
|
ch = text[i]
|
||||||
|
if '\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f' or ch in ',。、;:!?""''()【】《》—…·[]%%':
|
||||||
|
tokens.append(ch)
|
||||||
|
i += 1
|
||||||
|
elif ch.isascii() and ch.isalnum():
|
||||||
|
j = i
|
||||||
|
while j < len(text) and text[j].isascii() and text[j].isalnum():
|
||||||
|
j += 1
|
||||||
|
tokens.append(text[i:j])
|
||||||
|
i = j
|
||||||
|
else:
|
||||||
|
tokens.append(ch)
|
||||||
|
i += 1
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
class ContractEditor:
|
||||||
|
"""合同修订编辑器。一个实例对应一份合同文件。"""
|
||||||
|
|
||||||
|
def __init__(self, filepath):
|
||||||
|
self.filepath = Path(filepath)
|
||||||
|
with open(filepath, 'rb') as f:
|
||||||
|
self.original_bytes = f.read()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
|
||||||
|
self.doc_xml = z.read('word/document.xml')
|
||||||
|
|
||||||
|
self.tree = etree.fromstring(self.doc_xml)
|
||||||
|
self.body = self.tree.find(qn('body'))
|
||||||
|
self._rev_id = 100
|
||||||
|
self._revision_date = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
self._author = 'WB'
|
||||||
|
self._rsid = '00AA0001'
|
||||||
|
|
||||||
|
# 提取原文格式(核心:避免每次猜错格式)
|
||||||
|
self._body_rpr = None # 正文格式(最常见的非加粗rPr)
|
||||||
|
self._title_rpr = None # 条款标题格式(加粗的rPr)
|
||||||
|
self._body_ppr = None
|
||||||
|
self._extract_formats()
|
||||||
|
|
||||||
|
def _extract_formats(self):
|
||||||
|
"""从原文提取正文和标题的rPr。
|
||||||
|
策略:
|
||||||
|
- 正文格式:统计所有run的rPr,取出现最多的非加粗rPr
|
||||||
|
- 标题格式:优先从条款编号标题段落(如"7.索赔条款")提取rPr,
|
||||||
|
而非简单取第一个加粗run(可能是合同大标题,字号不同)
|
||||||
|
- 如果条款标题不加粗,标题格式回退到正文格式"""
|
||||||
|
import re
|
||||||
|
rpr_map = {} # serialized_rpr -> (count, rpr_element)
|
||||||
|
clause_title_rpr = None # 从条款编号标题提取的格式
|
||||||
|
first_bold_rpr = None # 第一个加粗run的格式(fallback)
|
||||||
|
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
# 获取段落全文,判断是否是条款编号标题(如 "7.索赔条款" "5.伴随服务")
|
||||||
|
p_text = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}')).strip()
|
||||||
|
is_clause_title = bool(re.match(r'^\d+[..、]\s*\S', p_text)) and len(p_text) < 30
|
||||||
|
|
||||||
|
for r in p.findall(qn('r')):
|
||||||
|
rpr = r.find(qn('rPr'))
|
||||||
|
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||||
|
if not txt.strip() or len(txt) < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if rpr is not None:
|
||||||
|
is_bold = rpr.find(qn('b')) is not None
|
||||||
|
key = etree.tostring(rpr, encoding='unicode')
|
||||||
|
|
||||||
|
if is_bold and first_bold_rpr is None:
|
||||||
|
first_bold_rpr = rpr
|
||||||
|
|
||||||
|
# 优先从条款标题段落提取标题格式
|
||||||
|
if is_clause_title and clause_title_rpr is None:
|
||||||
|
clause_title_rpr = rpr
|
||||||
|
|
||||||
|
if not is_bold:
|
||||||
|
if key not in rpr_map:
|
||||||
|
rpr_map[key] = [0, rpr]
|
||||||
|
rpr_map[key][0] += 1
|
||||||
|
|
||||||
|
if self._body_ppr is None:
|
||||||
|
ppr = p.find(qn('pPr'))
|
||||||
|
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
|
||||||
|
if ppr is not None and len(txt) > 20:
|
||||||
|
self._body_ppr = ppr
|
||||||
|
|
||||||
|
if rpr_map:
|
||||||
|
best = max(rpr_map.values(), key=lambda x: x[0])
|
||||||
|
self._body_rpr = best[1]
|
||||||
|
|
||||||
|
# 标题格式优先级:条款编号标题 > 第一个加粗run > 正文格式
|
||||||
|
self._title_rpr = clause_title_rpr or first_bold_rpr or self._body_rpr
|
||||||
|
|
||||||
|
if self._title_rpr is None and self._body_rpr is not None:
|
||||||
|
self._title_rpr = copy.deepcopy(self._body_rpr)
|
||||||
|
etree.SubElement(self._title_rpr, qn('b'))
|
||||||
|
|
||||||
|
def _next_id(self):
|
||||||
|
self._rev_id += 1
|
||||||
|
return str(self._rev_id)
|
||||||
|
|
||||||
|
def _mk_del(self, text, rpr=None):
|
||||||
|
d = etree.Element(qn('del'))
|
||||||
|
d.set(qn('id'), self._next_id())
|
||||||
|
d.set(qn('author'), self._author)
|
||||||
|
d.set(qn('date'), self._revision_date)
|
||||||
|
r = etree.SubElement(d, qn('r'))
|
||||||
|
r.set(qn('rsidDel'), self._rsid)
|
||||||
|
if rpr is not None:
|
||||||
|
r.append(copy.deepcopy(rpr))
|
||||||
|
t = etree.SubElement(r, qn('delText'))
|
||||||
|
t.set(XML_SPACE, 'preserve')
|
||||||
|
t.text = text
|
||||||
|
return d
|
||||||
|
|
||||||
|
def _mk_ins(self, text, rpr=None):
|
||||||
|
i = etree.Element(qn('ins'))
|
||||||
|
i.set(qn('id'), self._next_id())
|
||||||
|
i.set(qn('author'), self._author)
|
||||||
|
i.set(qn('date'), self._revision_date)
|
||||||
|
r = etree.SubElement(i, qn('r'))
|
||||||
|
r.set(qn('rsidR'), self._rsid)
|
||||||
|
if rpr is not None:
|
||||||
|
r.append(copy.deepcopy(rpr))
|
||||||
|
t = etree.SubElement(r, qn('t'))
|
||||||
|
t.set(XML_SPACE, 'preserve')
|
||||||
|
t.text = text
|
||||||
|
return i
|
||||||
|
|
||||||
|
def _mk_run(self, text, rpr=None):
|
||||||
|
r = etree.Element(qn('r'))
|
||||||
|
if rpr is not None:
|
||||||
|
r.append(copy.deepcopy(rpr))
|
||||||
|
t = etree.SubElement(r, qn('t'))
|
||||||
|
t.set(XML_SPACE, 'preserve')
|
||||||
|
t.text = text
|
||||||
|
return r
|
||||||
|
|
||||||
|
def get_para_text(self, p):
|
||||||
|
"""获取段落的原始文本(不含删除标记中的文本)"""
|
||||||
|
return ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
|
||||||
|
|
||||||
|
def find_para(self, search_text):
|
||||||
|
"""查找包含指定文本的段落"""
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
if search_text in self.get_para_text(p):
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
def tracked_replace(self, old_text, new_text):
|
||||||
|
"""在整个文档中查找old_text并用修订模式替换为new_text。
|
||||||
|
使用字符级tokenizer+difflib实现精准修订。
|
||||||
|
返回True如果成功。"""
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
runs = p.findall(f'.//{qn("r")}')
|
||||||
|
if not runs:
|
||||||
|
continue
|
||||||
|
full = ''.join(
|
||||||
|
''.join(t.text or '' for t in r.findall(qn('t')))
|
||||||
|
for r in runs
|
||||||
|
)
|
||||||
|
if old_text not in full:
|
||||||
|
continue
|
||||||
|
|
||||||
|
start = full.index(old_text)
|
||||||
|
end = start + len(old_text)
|
||||||
|
|
||||||
|
# 获取匹配位置的rPr
|
||||||
|
rpr = None
|
||||||
|
pos = 0
|
||||||
|
for r in runs:
|
||||||
|
rt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||||
|
if pos + len(rt) > start:
|
||||||
|
rpr = r.find(qn('rPr'))
|
||||||
|
break
|
||||||
|
pos += len(rt)
|
||||||
|
|
||||||
|
# 生成diff元素
|
||||||
|
if new_text == '':
|
||||||
|
elems = [self._mk_del(old_text, rpr)]
|
||||||
|
else:
|
||||||
|
ot = cjk_tokenize(old_text)
|
||||||
|
nt = cjk_tokenize(new_text)
|
||||||
|
matcher = difflib.SequenceMatcher(None, ot, nt)
|
||||||
|
elems = []
|
||||||
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
|
if tag == 'equal':
|
||||||
|
elems.append(self._mk_run(''.join(ot[i1:i2]), rpr))
|
||||||
|
elif tag == 'delete':
|
||||||
|
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
|
||||||
|
elif tag == 'insert':
|
||||||
|
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
|
||||||
|
elif tag == 'replace':
|
||||||
|
elems.append(self._mk_del(''.join(ot[i1:i2]), rpr))
|
||||||
|
elems.append(self._mk_ins(''.join(nt[j1:j2]), rpr))
|
||||||
|
|
||||||
|
# 定位受影响的runs并替换
|
||||||
|
pos = 0
|
||||||
|
first = last = None
|
||||||
|
prefix_text = suffix_text = ""
|
||||||
|
for idx, r in enumerate(runs):
|
||||||
|
rt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||||
|
run_end = pos + len(rt)
|
||||||
|
if run_end > start and pos < end:
|
||||||
|
if first is None:
|
||||||
|
first = idx
|
||||||
|
prefix_text = full[pos:start]
|
||||||
|
last = idx
|
||||||
|
suffix_text = full[end:run_end] if run_end > end else ""
|
||||||
|
pos = run_end
|
||||||
|
|
||||||
|
if first is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ref = runs[first]
|
||||||
|
# Find the actual paragraph (w:p) element to insert into
|
||||||
|
para_elem = p
|
||||||
|
# Determine insert position: find ref or its ancestor that is a direct child of p
|
||||||
|
ref_ancestor = ref
|
||||||
|
while ref_ancestor.getparent() is not para_elem and ref_ancestor.getparent() is not None:
|
||||||
|
ref_ancestor = ref_ancestor.getparent()
|
||||||
|
insert_pos = list(para_elem).index(ref_ancestor)
|
||||||
|
|
||||||
|
# Remove runs (each from its own parent)
|
||||||
|
for idx in range(last, first - 1, -1):
|
||||||
|
r = runs[idx]
|
||||||
|
r_parent = r.getparent()
|
||||||
|
r_parent.remove(r)
|
||||||
|
# If parent (e.g. w:ins) is now empty, remove it too
|
||||||
|
if r_parent is not para_elem and len(r_parent) == 0:
|
||||||
|
gp = r_parent.getparent()
|
||||||
|
if gp is not None:
|
||||||
|
gp.remove(r_parent)
|
||||||
|
|
||||||
|
ip = insert_pos
|
||||||
|
if prefix_text:
|
||||||
|
para_elem.insert(ip, self._mk_run(prefix_text, rpr))
|
||||||
|
ip += 1
|
||||||
|
for e in elems:
|
||||||
|
para_elem.insert(ip, e)
|
||||||
|
ip += 1
|
||||||
|
if suffix_text:
|
||||||
|
para_elem.insert(ip, self._mk_run(suffix_text, rpr))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_leading_whitespace(self, para):
|
||||||
|
"""从段落中提取前导空格/tab模式。
|
||||||
|
很多中文文档的缩进不是通过w:ind实现的,而是通过文本中的空格字符。"""
|
||||||
|
for r in para.findall(qn('r')):
|
||||||
|
# Skip deleted runs
|
||||||
|
if r.getparent().tag == qn('del'):
|
||||||
|
continue
|
||||||
|
for t in r.findall(qn('t')):
|
||||||
|
if t.text:
|
||||||
|
# Extract leading whitespace
|
||||||
|
stripped = t.text.lstrip()
|
||||||
|
if stripped: # Has actual content after whitespace
|
||||||
|
return t.text[:len(t.text) - len(stripped)]
|
||||||
|
elif t.text.isspace(): # Entire run is whitespace
|
||||||
|
return t.text
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def add_clause(self, full_text, after_search, use_title_format=False):
|
||||||
|
"""在包含after_search的段落之后插入新条款段落。
|
||||||
|
|
||||||
|
full_text: 新条款全文
|
||||||
|
after_search: 在包含此文本的段落之后插入
|
||||||
|
use_title_format: True=标题格式(加粗),False=正文格式
|
||||||
|
"""
|
||||||
|
ref_para = self.find_para(after_search)
|
||||||
|
if ref_para is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
rpr = self._title_rpr if use_title_format else self._body_rpr
|
||||||
|
ppr = ref_para.find(qn('pPr')) or self._body_ppr
|
||||||
|
|
||||||
|
# 复制相邻段落的前导空格模式
|
||||||
|
leading_ws = self._get_leading_whitespace(ref_para)
|
||||||
|
if leading_ws and not full_text.startswith(leading_ws):
|
||||||
|
full_text = leading_ws + full_text
|
||||||
|
|
||||||
|
new_p = etree.Element(qn('p'))
|
||||||
|
if ppr is not None:
|
||||||
|
new_p.append(copy.deepcopy(ppr))
|
||||||
|
new_p.append(self._mk_ins(full_text, rpr))
|
||||||
|
|
||||||
|
idx = list(self.body).index(ref_para)
|
||||||
|
self.body.insert(idx + 1, new_p)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def add_clause_before(self, full_text, before_search, use_title_format=False):
|
||||||
|
"""在包含before_search的段落之前插入新条款段落。"""
|
||||||
|
ref_para = self.find_para(before_search)
|
||||||
|
if ref_para is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
rpr = self._title_rpr if use_title_format else self._body_rpr
|
||||||
|
ppr = ref_para.find(qn('pPr')) or self._body_ppr
|
||||||
|
|
||||||
|
# 复制相邻段落的前导空格模式
|
||||||
|
leading_ws = self._get_leading_whitespace(ref_para)
|
||||||
|
if leading_ws and not full_text.startswith(leading_ws):
|
||||||
|
full_text = leading_ws + full_text
|
||||||
|
|
||||||
|
new_p = etree.Element(qn('p'))
|
||||||
|
if ppr is not None:
|
||||||
|
new_p.append(copy.deepcopy(ppr))
|
||||||
|
new_p.append(self._mk_ins(full_text, rpr))
|
||||||
|
|
||||||
|
idx = list(self.body).index(ref_para)
|
||||||
|
self.body.insert(idx, new_p)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def add_mixed_clause(self, title_text, content_text, after_search):
|
||||||
|
"""插入标题加粗+内容不加粗的新条款(两个段落)。
|
||||||
|
用于原文标题和内容分行的合同格式。"""
|
||||||
|
ref_para = self.find_para(after_search)
|
||||||
|
if ref_para is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
ppr = ref_para.find(qn('pPr')) or self._body_ppr
|
||||||
|
idx = list(self.body).index(ref_para)
|
||||||
|
|
||||||
|
# 复制相邻段落的前导空格模式
|
||||||
|
leading_ws = self._get_leading_whitespace(ref_para)
|
||||||
|
if leading_ws:
|
||||||
|
if not title_text.startswith(leading_ws):
|
||||||
|
title_text = leading_ws + title_text
|
||||||
|
if not content_text.startswith(leading_ws):
|
||||||
|
content_text = leading_ws + content_text
|
||||||
|
|
||||||
|
p_title = etree.Element(qn('p'))
|
||||||
|
if ppr: p_title.append(copy.deepcopy(ppr))
|
||||||
|
p_title.append(self._mk_ins(title_text, self._title_rpr))
|
||||||
|
self.body.insert(idx + 1, p_title)
|
||||||
|
|
||||||
|
p_content = etree.Element(qn('p'))
|
||||||
|
if ppr: p_content.append(copy.deepcopy(ppr))
|
||||||
|
p_content.append(self._mk_ins(content_text, self._body_rpr))
|
||||||
|
self.body.insert(idx + 2, p_content)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def renumber_clause(self, old_num, new_num):
|
||||||
|
"""把条款编号从old_num改为new_num(修订模式)。
|
||||||
|
从后往前扫描,避免重复修改。"""
|
||||||
|
changed = 0
|
||||||
|
for p in reversed(self.body.findall(qn('p'))):
|
||||||
|
runs = p.findall(f'.//{qn("r")}')
|
||||||
|
for r in runs:
|
||||||
|
for t in r.findall(qn('t')):
|
||||||
|
if t.text and old_num in t.text:
|
||||||
|
rpr_e = r.find(qn('rPr'))
|
||||||
|
parent = r.getparent()
|
||||||
|
idx_r = list(parent).index(r)
|
||||||
|
|
||||||
|
pos = t.text.index(old_num)
|
||||||
|
prefix = t.text[:pos]
|
||||||
|
suffix = t.text[pos + len(old_num):]
|
||||||
|
|
||||||
|
parent.remove(r)
|
||||||
|
ip = idx_r
|
||||||
|
if prefix:
|
||||||
|
parent.insert(ip, self._mk_run(prefix, rpr_e))
|
||||||
|
ip += 1
|
||||||
|
parent.insert(ip, self._mk_del(old_num, rpr_e))
|
||||||
|
ip += 1
|
||||||
|
parent.insert(ip, self._mk_ins(new_num, rpr_e))
|
||||||
|
ip += 1
|
||||||
|
if suffix:
|
||||||
|
parent.insert(ip, self._mk_run(suffix, rpr_e))
|
||||||
|
|
||||||
|
changed += 1
|
||||||
|
break
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def renumber_range(self, start, shift=1):
|
||||||
|
"""从start开始,所有现有条款编号+shift。从后往前处理。
|
||||||
|
|
||||||
|
注意:先调用此方法腾出编号空间,再插入新条款。
|
||||||
|
例:要在18后插入新19条:
|
||||||
|
editor.renumber_range(19, 1) # 19→20, 20→21, 21→22
|
||||||
|
editor.add_clause_before("19.新条款内容", before_search="20.合同生效")
|
||||||
|
"""
|
||||||
|
max_num = 0
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
txt = self.get_para_text(p)
|
||||||
|
for m in re.finditer(r'(\d+)[..]', txt):
|
||||||
|
n = int(m.group(1))
|
||||||
|
if n > max_num:
|
||||||
|
max_num = n
|
||||||
|
|
||||||
|
for n in range(max_num, start - 1, -1):
|
||||||
|
self.renumber_clause(f'{n}.', f'{n + shift}.')
|
||||||
|
self.renumber_clause(f'{n}.', f'{n + shift}.')
|
||||||
|
|
||||||
|
def renumber_chinese(self, old_cn, new_cn):
|
||||||
|
"""中文编号顺延,如 "第十三条" → "第十四条"。"""
|
||||||
|
return self.renumber_clause(old_cn, new_cn)
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
"""交付前验证。返回错误列表,空列表=通过。"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# 1. 编号连续性
|
||||||
|
clause_nums = []
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
accepted = ''
|
||||||
|
for child in p:
|
||||||
|
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||||
|
if tag == 'r':
|
||||||
|
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
|
||||||
|
elif tag == 'ins':
|
||||||
|
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
|
||||||
|
m = re.match(r'^(\d+)[..]', accepted.strip())
|
||||||
|
if m:
|
||||||
|
clause_nums.append(int(m.group(1)))
|
||||||
|
|
||||||
|
main_clauses = sorted(set(clause_nums))
|
||||||
|
for i in range(1, len(main_clauses)):
|
||||||
|
if main_clauses[i] - main_clauses[i-1] > 1:
|
||||||
|
errors.append(f"编号跳跃: {main_clauses[i-1]}→{main_clauses[i]},缺少{main_clauses[i-1]+1}")
|
||||||
|
|
||||||
|
# 2. 字号一致性(WB的ins内容 vs 原文正文)
|
||||||
|
if self._body_rpr is not None:
|
||||||
|
body_sz = None
|
||||||
|
sz_elem = self._body_rpr.find(qn('sz'))
|
||||||
|
if sz_elem is not None:
|
||||||
|
body_sz = sz_elem.get(qn('val'))
|
||||||
|
|
||||||
|
if body_sz:
|
||||||
|
for ins in self.tree.findall(f'.//{qn("ins")}'):
|
||||||
|
if ins.get(qn('author')) != self._author:
|
||||||
|
continue
|
||||||
|
for r in ins.findall(qn('r')):
|
||||||
|
rpr = r.find(qn('rPr'))
|
||||||
|
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||||
|
if not txt.strip():
|
||||||
|
continue
|
||||||
|
if rpr is not None:
|
||||||
|
ins_sz = rpr.find(qn('sz'))
|
||||||
|
if ins_sz is not None:
|
||||||
|
val = ins_sz.get(qn('val'))
|
||||||
|
is_bold = rpr.find(qn('b')) is not None
|
||||||
|
if val != body_sz and not is_bold:
|
||||||
|
errors.append(f"字号不一致: ins sz={val} vs 原文sz={body_sz},'{txt[:30]}'")
|
||||||
|
|
||||||
|
# 3. 加粗规则(内容不应加粗)
|
||||||
|
for ins in self.tree.findall(f'.//{qn("ins")}'):
|
||||||
|
if ins.get(qn('author')) != self._author:
|
||||||
|
continue
|
||||||
|
for r in ins.findall(qn('r')):
|
||||||
|
rpr = r.find(qn('rPr'))
|
||||||
|
txt = ''.join(t.text or '' for t in r.findall(qn('t')))
|
||||||
|
if not txt.strip() or len(txt.strip()) < 5:
|
||||||
|
continue
|
||||||
|
is_bold = rpr is not None and rpr.find(qn('b')) is not None
|
||||||
|
is_clause_title = bool(re.match(r'^\d+[..]\S', txt.strip())) or bool(re.match(r'^第.{1,3}条', txt.strip()))
|
||||||
|
if is_bold and not is_clause_title:
|
||||||
|
errors.append(f"不应加粗: '{txt[:40]}'")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def dump_numbering(self):
|
||||||
|
"""输出accepted view的编号序列,用于人工确认"""
|
||||||
|
result = []
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
accepted = ''
|
||||||
|
for child in p:
|
||||||
|
tag = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
||||||
|
if tag == 'r':
|
||||||
|
accepted += ''.join(t.text or '' for t in child.findall(qn('t')))
|
||||||
|
elif tag == 'ins':
|
||||||
|
accepted += ''.join(t.text or '' for t in child.findall(f'.//{qn("t")}'))
|
||||||
|
m = re.match(r'^(\d+)[..]', accepted.strip())
|
||||||
|
if m:
|
||||||
|
result.append(f"{m.group(1)}. {accepted.strip()[:60]}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def save(self, output_path):
|
||||||
|
"""保存修订后的文件"""
|
||||||
|
new_doc_xml = etree.tostring(self.tree, xml_declaration=True,
|
||||||
|
encoding='UTF-8', standalone=True)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as z:
|
||||||
|
settings = z.read('word/settings.xml')
|
||||||
|
stree = etree.fromstring(settings)
|
||||||
|
if stree.find(f'.//{qn("trackRevisions")}') is None:
|
||||||
|
stree.append(etree.Element(qn('trackRevisions')))
|
||||||
|
new_settings = etree.tostring(stree, xml_declaration=True,
|
||||||
|
encoding='UTF-8', standalone=True)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(io.BytesIO(self.original_bytes)) as zin:
|
||||||
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for item in zin.infolist():
|
||||||
|
if item.filename == 'word/document.xml':
|
||||||
|
zout.writestr(item, new_doc_xml)
|
||||||
|
elif item.filename == 'word/settings.xml':
|
||||||
|
zout.writestr(item, new_settings)
|
||||||
|
else:
|
||||||
|
zout.writestr(item, zin.read(item.filename))
|
||||||
|
|
||||||
|
with open(output_path, 'wb') as f:
|
||||||
|
f.write(buf.getvalue())
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
class ZhujiajaoOpinion:
|
||||||
|
"""朱家角审查意见表格填写器。严格使用模板结构,不自创格式。"""
|
||||||
|
|
||||||
|
TEMPLATE_PATH = Path.home() / ".hermes/shared/模版库/朱家角 审查意见【模板】.docx"
|
||||||
|
|
||||||
|
def __init__(self, template_path=None):
|
||||||
|
tpath = Path(template_path) if template_path else self.TEMPLATE_PATH
|
||||||
|
with open(tpath, 'rb') as f:
|
||||||
|
self.tmpl_bytes = f.read()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as z:
|
||||||
|
self.doc_xml = z.read('word/document.xml')
|
||||||
|
|
||||||
|
self.tree = etree.fromstring(self.doc_xml)
|
||||||
|
self.body = self.tree.find(qn('body'))
|
||||||
|
|
||||||
|
def fill(self, contract_name, items, has_modifications=True):
|
||||||
|
"""填写审查意见。
|
||||||
|
|
||||||
|
contract_name: 合同名称(填入标题《》中间)
|
||||||
|
items: [(条文位置, 原文, 修订后), ...]
|
||||||
|
has_modifications: False则保留"无法律修改意见"
|
||||||
|
"""
|
||||||
|
# 1. 填标题——找到空格run替换
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
runs = p.findall(f'.//{qn("r")}')
|
||||||
|
for r in runs:
|
||||||
|
for t in r.findall(qn('t')):
|
||||||
|
if t.text and t.text.strip() == '' and len(t.text) >= 2:
|
||||||
|
parent_txt = ''.join(
|
||||||
|
tt.text or '' for rr in runs for tt in rr.findall(qn('t'))
|
||||||
|
)
|
||||||
|
if '关于《' in parent_txt:
|
||||||
|
t.text = contract_name
|
||||||
|
|
||||||
|
# 2. 处理"无法律修改意见"
|
||||||
|
if has_modifications:
|
||||||
|
for p in self.body.findall(qn('p')):
|
||||||
|
txt = ''.join(t.text or '' for t in p.findall(f'.//{qn("t")}'))
|
||||||
|
if '无法律修改意见' in txt:
|
||||||
|
for r in p.findall(f'.//{qn("r")}'):
|
||||||
|
for t in r.findall(qn('t')):
|
||||||
|
if '无法律修改意见' in (t.text or ''):
|
||||||
|
t.text = ''
|
||||||
|
|
||||||
|
# 3. 填表格
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
|
||||||
|
tbl = self.body.find(qn('tbl'))
|
||||||
|
if tbl is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = tbl.findall(qn('tr'))
|
||||||
|
# Row 0 = header, Row 1+ = data rows
|
||||||
|
|
||||||
|
# 获取表头rPr
|
||||||
|
header_rpr = None
|
||||||
|
for hc in rows[0].findall(qn('tc')):
|
||||||
|
for hr in hc.findall(f'.//{qn("r")}'):
|
||||||
|
rr = hr.find(qn('rPr'))
|
||||||
|
if rr:
|
||||||
|
header_rpr = rr
|
||||||
|
break
|
||||||
|
if header_rpr:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 确保有足够数据行
|
||||||
|
template_row = rows[1] if len(rows) > 1 else None
|
||||||
|
while len(tbl.findall(qn('tr'))) - 1 < len(items):
|
||||||
|
if template_row is not None:
|
||||||
|
tbl.append(copy.deepcopy(template_row))
|
||||||
|
|
||||||
|
rows = tbl.findall(qn('tr'))
|
||||||
|
|
||||||
|
# 填写数据
|
||||||
|
for i, (clause, orig_text, modified_text) in enumerate(items):
|
||||||
|
if i + 1 >= len(rows):
|
||||||
|
break
|
||||||
|
row = rows[i + 1]
|
||||||
|
cells = row.findall(qn('tc'))
|
||||||
|
if len(cells) < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for ci, text in enumerate([clause, orig_text, modified_text]):
|
||||||
|
cell = cells[ci]
|
||||||
|
p = cell.find(qn('p'))
|
||||||
|
if p is None:
|
||||||
|
p = etree.SubElement(cell, qn('p'))
|
||||||
|
for r in p.findall(qn('r')):
|
||||||
|
p.remove(r)
|
||||||
|
|
||||||
|
r = etree.SubElement(p, qn('r'))
|
||||||
|
if header_rpr:
|
||||||
|
new_rpr = copy.deepcopy(header_rpr)
|
||||||
|
b = new_rpr.find(qn('b'))
|
||||||
|
if b is not None:
|
||||||
|
new_rpr.remove(b)
|
||||||
|
if '注:' in text:
|
||||||
|
color = new_rpr.find(qn('color'))
|
||||||
|
if color is None:
|
||||||
|
color = etree.SubElement(new_rpr, qn('color'))
|
||||||
|
color.set(qn('val'), 'FF0000')
|
||||||
|
r.append(new_rpr)
|
||||||
|
|
||||||
|
t = etree.SubElement(r, qn('t'))
|
||||||
|
t.set(XML_SPACE, 'preserve')
|
||||||
|
t.text = text
|
||||||
|
|
||||||
|
# 删除多余空行
|
||||||
|
rows = tbl.findall(qn('tr'))
|
||||||
|
for i in range(len(rows) - 1, len(items), -1):
|
||||||
|
tbl.remove(rows[i])
|
||||||
|
|
||||||
|
def save(self, output_path):
|
||||||
|
new_doc = etree.tostring(self.tree, xml_declaration=True,
|
||||||
|
encoding='UTF-8', standalone=True)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(io.BytesIO(self.tmpl_bytes)) as zin:
|
||||||
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for item in zin.infolist():
|
||||||
|
if item.filename == 'word/document.xml':
|
||||||
|
zout.writestr(item, new_doc)
|
||||||
|
else:
|
||||||
|
zout.writestr(item, zin.read(item.filename))
|
||||||
|
with open(output_path, 'wb') as f:
|
||||||
|
f.write(buf.getvalue())
|
||||||
|
return output_path
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
contract_preprocess.py — 合同预处理:检测并切割非审查图片内容
|
||||||
|
|
||||||
|
用途:在workflow审查前,检测合同末尾的纯图片附件(如招标公告截图、中标通知书等),
|
||||||
|
切割出来保存,审查完后再还原。
|
||||||
|
|
||||||
|
判断逻辑:
|
||||||
|
1. 扫描文件结构:文字段落数 vs 图片段落数
|
||||||
|
2. 全文/大部分是图片(扫描件合同)→ 不切割,标记需OCR
|
||||||
|
3. 正文文字+末尾图片附件 → 切割末尾图片区域
|
||||||
|
4. 切割点:从最后一个"纯文字附件"结束后,到第一个"纯图片附件"开始
|
||||||
|
|
||||||
|
输出:
|
||||||
|
- {basename}_stripped.docx — 去掉图片附件的版本(供workflow处理)
|
||||||
|
- {basename}_cutdata.json — 切割信息(供还原用)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import zipfile, json, os, sys, re
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||||
|
R_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
|
||||||
|
A_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main'
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_contract(docx_path):
|
||||||
|
"""Analyze contract structure, return analysis dict"""
|
||||||
|
with zipfile.ZipFile(docx_path) as z:
|
||||||
|
doc = etree.fromstring(z.read('word/document.xml'))
|
||||||
|
media_files = {n: z.getinfo(n).file_size for n in z.namelist() if n.startswith('word/media/')}
|
||||||
|
|
||||||
|
body = doc.find(f'{{{W}}}body')
|
||||||
|
paras = body.findall(f'{{{W}}}p')
|
||||||
|
|
||||||
|
paragraphs = []
|
||||||
|
total_text_chars = 0
|
||||||
|
total_img_paras = 0
|
||||||
|
|
||||||
|
for i, p in enumerate(paras):
|
||||||
|
texts = p.findall(f'.//{{{W}}}t')
|
||||||
|
text = ''.join(t.text or '' for t in texts).strip()
|
||||||
|
|
||||||
|
has_img = any('drawing' in (e.tag if isinstance(e.tag, str) else '') for e in p.iter())
|
||||||
|
|
||||||
|
blips = list(p.iter(f'{{{A_NS}}}blip'))
|
||||||
|
img_rids = [b.get(f'{{{R_NS}}}embed', '') for b in blips]
|
||||||
|
|
||||||
|
total_text_chars += len(text)
|
||||||
|
if has_img:
|
||||||
|
total_img_paras += 1
|
||||||
|
|
||||||
|
paragraphs.append({
|
||||||
|
'idx': i,
|
||||||
|
'text': text,
|
||||||
|
'text_len': len(text),
|
||||||
|
'has_img': has_img,
|
||||||
|
'img_rids': img_rids,
|
||||||
|
'is_appendix_heading': bool(re.match(r'^附件[一二三四五六七八九十\d]+[::、]', text)),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_paras': len(paras),
|
||||||
|
'total_text_chars': total_text_chars,
|
||||||
|
'total_img_paras': total_img_paras,
|
||||||
|
'media_files': media_files,
|
||||||
|
'total_media_bytes': sum(media_files.values()),
|
||||||
|
'paragraphs': paragraphs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_cut_zone(analysis):
|
||||||
|
"""Detect if there's a tail image zone to cut."""
|
||||||
|
paras = analysis['paragraphs']
|
||||||
|
total = analysis['total_paras']
|
||||||
|
|
||||||
|
text_paras = sum(1 for p in paras if p['text_len'] > 0 and not p['has_img'])
|
||||||
|
img_paras = analysis['total_img_paras']
|
||||||
|
|
||||||
|
if text_paras == 0 and img_paras > 0:
|
||||||
|
return {'action': 'ocr', 'reason': '全文无文字段落,疑似扫描件合同'}
|
||||||
|
|
||||||
|
if img_paras == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
img_ratio = img_paras / max(1, text_paras + img_paras)
|
||||||
|
if img_ratio > 0.5:
|
||||||
|
return {'action': 'ocr', 'reason': f'图片段落占比{img_ratio:.0%},疑似扫描件合同'}
|
||||||
|
|
||||||
|
# Find tail image zones
|
||||||
|
image_zones = []
|
||||||
|
i = 0
|
||||||
|
while i < total:
|
||||||
|
p = paras[i]
|
||||||
|
if p['is_appendix_heading']:
|
||||||
|
zone_start = i
|
||||||
|
zone_has_images = False
|
||||||
|
zone_has_text_content = False
|
||||||
|
j = i + 1
|
||||||
|
|
||||||
|
while j < total:
|
||||||
|
next_p = paras[j]
|
||||||
|
if next_p['is_appendix_heading']:
|
||||||
|
break
|
||||||
|
if next_p['has_img']:
|
||||||
|
zone_has_images = True
|
||||||
|
if next_p['text_len'] > 20 and not next_p['has_img']:
|
||||||
|
zone_has_text_content = True
|
||||||
|
j += 1
|
||||||
|
|
||||||
|
image_zones.append({
|
||||||
|
'start_idx': zone_start,
|
||||||
|
'end_idx': j - 1,
|
||||||
|
'heading': p['text'],
|
||||||
|
'has_images': zone_has_images,
|
||||||
|
'has_text': zone_has_text_content,
|
||||||
|
'is_image_only': zone_has_images and not zone_has_text_content,
|
||||||
|
})
|
||||||
|
i = j
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Find consecutive image-only appendices at the tail
|
||||||
|
tail_cut_zones = []
|
||||||
|
for zone in reversed(image_zones):
|
||||||
|
if zone['is_image_only']:
|
||||||
|
tail_cut_zones.insert(0, zone)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not tail_cut_zones:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cut_start = tail_cut_zones[0]['start_idx']
|
||||||
|
cut_headings = [z['heading'] for z in tail_cut_zones]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'action': 'cut',
|
||||||
|
'cut_start_idx': cut_start,
|
||||||
|
'cut_end_idx': total - 1,
|
||||||
|
'cut_headings': cut_headings,
|
||||||
|
'reason': f'末尾{len(tail_cut_zones)}个附件为纯图片:{", ".join(cut_headings)}',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def preprocess_contract(docx_path, output_dir=None):
|
||||||
|
"""Main entry: analyze and optionally strip tail images."""
|
||||||
|
if output_dir is None:
|
||||||
|
output_dir = os.path.dirname(docx_path) or '.'
|
||||||
|
|
||||||
|
basename = os.path.splitext(os.path.basename(docx_path))[0]
|
||||||
|
|
||||||
|
analysis = analyze_contract(docx_path)
|
||||||
|
cut_info = detect_cut_zone(analysis)
|
||||||
|
|
||||||
|
print(f"\n=== 合同预处理分析 ===")
|
||||||
|
print(f"文件: {os.path.basename(docx_path)}")
|
||||||
|
print(f"段落数: {analysis['total_paras']}")
|
||||||
|
print(f"文字字符: {analysis['total_text_chars']}")
|
||||||
|
print(f"图片段落: {analysis['total_img_paras']}")
|
||||||
|
print(f"媒体文件: {len(analysis['media_files'])} ({analysis['total_media_bytes']:,} bytes)")
|
||||||
|
|
||||||
|
if cut_info is None:
|
||||||
|
print(f"结论: 无需切割")
|
||||||
|
return {'action': 'none', 'analysis': analysis}
|
||||||
|
|
||||||
|
if cut_info['action'] == 'ocr':
|
||||||
|
print(f"结论: {cut_info['reason']},需OCR处理")
|
||||||
|
return {'action': 'ocr', 'reason': cut_info['reason'], 'analysis': analysis}
|
||||||
|
|
||||||
|
cut_start = cut_info['cut_start_idx']
|
||||||
|
print(f"结论: 需切割 — {cut_info['reason']}")
|
||||||
|
print(f"切割点: 段落 #{cut_start}")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(docx_path) as z:
|
||||||
|
doc = etree.fromstring(z.read('word/document.xml'))
|
||||||
|
all_files = {}
|
||||||
|
for name in z.namelist():
|
||||||
|
all_files[name] = z.read(name)
|
||||||
|
|
||||||
|
body = doc.find(f'{{{W}}}body')
|
||||||
|
paras = body.findall(f'{{{W}}}p')
|
||||||
|
|
||||||
|
cut_paras_xml = []
|
||||||
|
for i in range(cut_start, len(paras)):
|
||||||
|
cut_paras_xml.append(etree.tostring(paras[i], encoding='unicode'))
|
||||||
|
|
||||||
|
for i in range(len(paras) - 1, cut_start - 1, -1):
|
||||||
|
body.remove(paras[i])
|
||||||
|
|
||||||
|
cut_rids = set()
|
||||||
|
for p_info in analysis['paragraphs'][cut_start:]:
|
||||||
|
cut_rids.update(p_info['img_rids'])
|
||||||
|
|
||||||
|
rels_xml = all_files.get('word/_rels/document.xml.rels', b'')
|
||||||
|
if isinstance(rels_xml, bytes):
|
||||||
|
rels_xml = rels_xml.decode()
|
||||||
|
rid_to_media = {}
|
||||||
|
for m in re.finditer(r'Id="(rId\d+)"[^/]*Target="(media/[^"]+)"', rels_xml):
|
||||||
|
rid_to_media[m.group(1)] = f'word/{m.group(2)}'
|
||||||
|
|
||||||
|
cut_media = {}
|
||||||
|
for rid in cut_rids:
|
||||||
|
media_path = rid_to_media.get(rid)
|
||||||
|
if media_path and media_path in all_files:
|
||||||
|
cut_media[media_path] = len(all_files[media_path])
|
||||||
|
|
||||||
|
stripped_path = os.path.join(output_dir, f'{basename}_stripped.docx')
|
||||||
|
all_files['word/document.xml'] = etree.tostring(doc, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(stripped_path, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for name, data in all_files.items():
|
||||||
|
zout.writestr(name, data)
|
||||||
|
|
||||||
|
cutdata = {
|
||||||
|
'original_file': os.path.basename(docx_path),
|
||||||
|
'cut_start_idx': cut_start,
|
||||||
|
'total_paras_original': len(paras) + len(cut_paras_xml),
|
||||||
|
'cut_paragraphs_xml': cut_paras_xml,
|
||||||
|
'cut_headings': cut_info['cut_headings'],
|
||||||
|
'cut_media_files': list(cut_media.keys()),
|
||||||
|
'reason': cut_info['reason'],
|
||||||
|
}
|
||||||
|
|
||||||
|
cutdata_path = os.path.join(output_dir, f'{basename}_cutdata.json')
|
||||||
|
with open(cutdata_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(cutdata, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
stripped_size = os.path.getsize(stripped_path)
|
||||||
|
original_size = os.path.getsize(docx_path)
|
||||||
|
|
||||||
|
print(f"\n输出:")
|
||||||
|
print(f" stripped: {stripped_path} ({stripped_size:,} bytes)")
|
||||||
|
print(f" cutdata: {cutdata_path}")
|
||||||
|
print(f" 大小变化: {original_size:,} → {stripped_size:,} bytes ({stripped_size/original_size:.0%})")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'action': 'cut',
|
||||||
|
'stripped_path': stripped_path,
|
||||||
|
'cutdata_path': cutdata_path,
|
||||||
|
'cut_info': cut_info,
|
||||||
|
'analysis': analysis,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def restore_contract(reviewed_path, cutdata_path, output_path):
|
||||||
|
"""Restore cut content back into the reviewed file."""
|
||||||
|
with open(cutdata_path, 'r', encoding='utf-8') as f:
|
||||||
|
cutdata = json.load(f)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(reviewed_path) as z:
|
||||||
|
doc = etree.fromstring(z.read('word/document.xml'))
|
||||||
|
all_files = {}
|
||||||
|
for name in z.namelist():
|
||||||
|
all_files[name] = z.read(name)
|
||||||
|
|
||||||
|
body = doc.find(f'{{{W}}}body')
|
||||||
|
sect_pr = body.find(f'{{{W}}}sectPr')
|
||||||
|
|
||||||
|
for para_xml in cutdata['cut_paragraphs_xml']:
|
||||||
|
para_elem = etree.fromstring(para_xml)
|
||||||
|
if sect_pr is not None:
|
||||||
|
sect_pr.addprevious(para_elem)
|
||||||
|
else:
|
||||||
|
body.append(para_elem)
|
||||||
|
|
||||||
|
original_dir = os.path.dirname(cutdata_path)
|
||||||
|
original_name = cutdata['original_file']
|
||||||
|
original_path = os.path.join(original_dir, original_name)
|
||||||
|
|
||||||
|
if os.path.exists(original_path):
|
||||||
|
with zipfile.ZipFile(original_path) as z_orig:
|
||||||
|
for media_file in cutdata.get('cut_media_files', []):
|
||||||
|
if media_file not in all_files and media_file in z_orig.namelist():
|
||||||
|
all_files[media_file] = z_orig.read(media_file)
|
||||||
|
print(f" 还原媒体文件: {media_file}")
|
||||||
|
|
||||||
|
all_files['word/document.xml'] = etree.tostring(doc, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for name, data in all_files.items():
|
||||||
|
zout.writestr(name, data)
|
||||||
|
|
||||||
|
restored_size = os.path.getsize(output_path)
|
||||||
|
print(f"\n=== 合同还原完成 ===")
|
||||||
|
print(f"还原文件: {output_path} ({restored_size:,} bytes)")
|
||||||
|
print(f"还原段落: {len(cutdata['cut_paragraphs_xml'])} 个")
|
||||||
|
print(f"还原附件: {', '.join(cutdata['cut_headings'])}")
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage:")
|
||||||
|
print(" 预处理: python contract_preprocess.py preprocess <input.docx> [output_dir]")
|
||||||
|
print(" 还原: python contract_preprocess.py restore <reviewed.docx> <cutdata.json> <output.docx>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
action = sys.argv[1]
|
||||||
|
|
||||||
|
if action == 'preprocess':
|
||||||
|
docx_path = sys.argv[2]
|
||||||
|
output_dir = sys.argv[3] if len(sys.argv) > 3 else None
|
||||||
|
result = preprocess_contract(docx_path, output_dir)
|
||||||
|
print(f"\nResult: {json.dumps({k: v for k, v in result.items() if k != 'analysis'}, ensure_ascii=False, indent=2)}")
|
||||||
|
|
||||||
|
elif action == 'restore':
|
||||||
|
reviewed_path = sys.argv[2]
|
||||||
|
cutdata_path = sys.argv[3]
|
||||||
|
output_path = sys.argv[4]
|
||||||
|
restore_contract(reviewed_path, cutdata_path, output_path)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f"Unknown action: {action}")
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""编号链诊断探针 — 一次性看清 docx 的自动编号/手动编号全貌。
|
||||||
|
|
||||||
|
用途:合同编号疑似错乱(重复/跳号/双号)时,动手改之前必跑此脚本。
|
||||||
|
它把三件事一次性摊开,让你判断「是我们WB改错的 / 他人修订重排的 / 还是源文件自带的潜伏自动编号」:
|
||||||
|
1. 每个段落:是否带 <w:numPr>(自动编号)、numId、ilvl、是否整段ins/del
|
||||||
|
2. numbering.xml 解析:numId→abstractNum→(numFmt, lvlText, start) ——
|
||||||
|
⚠️ start≠1 的 decimal 列表会渲染出「6、」之类的可见编号,但 run 里没有这个字!
|
||||||
|
这是最隐蔽的坑:源文件起草人给某段挂了 numId(start=6),OnlyOffice 自动显示「6、售后服务」,
|
||||||
|
而你在末尾新增条款时只数了手打的「1 2 3 4 5」,顺手编成「6」→ 与潜伏的自动6撞号。
|
||||||
|
3. 每段「接受所有修订后」的可见文本(去w:del、保w:ins),近似 OnlyOffice 接受后视图
|
||||||
|
|
||||||
|
用法: python numbering-diagnose.py <contract.docx>
|
||||||
|
.doc 先转换: soffice --headless --convert-to docx <file>.doc
|
||||||
|
"""
|
||||||
|
import sys, zipfile
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||||
|
|
||||||
|
def text_mode(p, mode):
|
||||||
|
"""mode='final': 接受所有修订后(去del,保ins). mode='orig': 修订前(去ins,保del)."""
|
||||||
|
parts = []
|
||||||
|
for node in p.iter():
|
||||||
|
if node.tag == W + 't':
|
||||||
|
anc, skip = node, False
|
||||||
|
while anc is not None:
|
||||||
|
if mode == 'final' and anc.tag == W + 'del':
|
||||||
|
skip = True; break
|
||||||
|
if mode == 'orig' and anc.tag == W + 'ins':
|
||||||
|
skip = True; break
|
||||||
|
anc = anc.getparent()
|
||||||
|
if not skip:
|
||||||
|
parts.append(node.text or '')
|
||||||
|
elif node.tag == W + 'delText' and mode == 'orig':
|
||||||
|
parts.append(node.text or '')
|
||||||
|
return ''.join(parts).strip()
|
||||||
|
|
||||||
|
def parse_numbering(z):
|
||||||
|
"""返回 numId -> (numFmt, lvlText, start) 仅 lvl0(够用于条款标题层)."""
|
||||||
|
out = {}
|
||||||
|
if 'word/numbering.xml' not in z.namelist():
|
||||||
|
return out
|
||||||
|
num = etree.fromstring(z.read('word/numbering.xml'))
|
||||||
|
n2a = {}
|
||||||
|
for n in num.findall(W + 'num'):
|
||||||
|
ab = n.find(W + 'abstractNumId')
|
||||||
|
if ab is not None:
|
||||||
|
n2a[n.get(W + 'numId')] = ab.get(W + 'val')
|
||||||
|
a2fmt = {}
|
||||||
|
for ab in num.findall(W + 'abstractNum'):
|
||||||
|
l0 = ab.find(W + 'lvl')
|
||||||
|
if l0 is not None:
|
||||||
|
fmt = l0.find(W + 'numFmt')
|
||||||
|
txt = l0.find(W + 'lvlText')
|
||||||
|
st = l0.find(W + 'start')
|
||||||
|
a2fmt[ab.get(W + 'abstractNumId')] = (
|
||||||
|
fmt.get(W + 'val') if fmt is not None else '?',
|
||||||
|
txt.get(W + 'val') if txt is not None else '',
|
||||||
|
st.get(W + 'val') if st is not None else '1',
|
||||||
|
)
|
||||||
|
for nid, aid in n2a.items():
|
||||||
|
out[nid] = a2fmt.get(aid, ('?', '', '1'))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def main(path):
|
||||||
|
z = zipfile.ZipFile(path)
|
||||||
|
root = etree.fromstring(z.read('word/document.xml'))
|
||||||
|
numinfo = parse_numbering(z)
|
||||||
|
|
||||||
|
print(f"### {path}\n")
|
||||||
|
print("=== numbering.xml: numId -> (numFmt, lvlText, start) ===")
|
||||||
|
if not numinfo:
|
||||||
|
print(" (无 numbering.xml — 全文应为手动文本编号)")
|
||||||
|
for nid, (fmt, txt, st) in sorted(numinfo.items()):
|
||||||
|
warn = ' ⚠️start≠1 会渲染潜伏编号!' if (fmt == 'decimal' and st != '1') else ''
|
||||||
|
print(f" numId={nid}: fmt={fmt}, lvlText='{txt}', start={st}{warn}")
|
||||||
|
print()
|
||||||
|
print("idx | numPr(自动) | rendered | ins/del | 文本(接受修订后)")
|
||||||
|
print("-" * 92)
|
||||||
|
for i, p in enumerate(root.findall('.//' + W + 'p')):
|
||||||
|
tf = text_mode(p, 'final')
|
||||||
|
if not tf:
|
||||||
|
continue
|
||||||
|
npr = p.find('.//' + W + 'numPr')
|
||||||
|
npinfo, rendered = '—', ''
|
||||||
|
if npr is not None:
|
||||||
|
nid_el = npr.find(W + 'numId')
|
||||||
|
il_el = npr.find(W + 'ilvl')
|
||||||
|
nid = nid_el.get(W + 'val') if nid_el is not None else '?'
|
||||||
|
il = il_el.get(W + 'val') if il_el is not None else '0'
|
||||||
|
npinfo = f"numId={nid},lvl={il}"
|
||||||
|
fmt, txt, st = numinfo.get(nid, ('?', '', '1'))
|
||||||
|
if fmt == 'decimal':
|
||||||
|
rendered = (txt or '%1、').replace('%1', st) # 该项首个渲染值(近似)
|
||||||
|
elif fmt == 'bullet':
|
||||||
|
rendered = '•'
|
||||||
|
elif fmt == 'none':
|
||||||
|
rendered = '(无)'
|
||||||
|
has_ins = p.find('.//' + W + 'ins') is not None
|
||||||
|
has_del = p.find('.//' + W + 'del') is not None
|
||||||
|
mk = ('INS' if has_ins else '') + ('/' if has_ins and has_del else '') + ('DEL' if has_del else '')
|
||||||
|
print(f"{i:3d} | {npinfo:18s} | {rendered:8s} | {mk:7s} | {tf[:46]}")
|
||||||
|
print()
|
||||||
|
print("判读要点:")
|
||||||
|
print(" - rendered 列非空 = OnlyOffice 会自动加这个编号(run里没有这串字)")
|
||||||
|
print(" - 手动编号: rendered='—' 且文本以「N、」开头 = 编号是写死的文字")
|
||||||
|
print(" - 若末尾新增条款(INS)的手打编号 与 上方某段 rendered 自动编号 相同 → 撞号")
|
||||||
|
print(" 正确做法: 新增手打编号应接续【rendered 自动值】往下编, 不是接续最后一个手打数字")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(__doc__); sys.exit(1)
|
||||||
|
main(sys.argv[1])
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# OnlyOffice x2t 渲染 docx → PDF
|
||||||
|
# 用途:用Maggie/Doro实际使用的渲染引擎(OnlyOffice)把合同docx渲染成PDF,
|
||||||
|
# 核对编号/格式的真实显示效果(与LibreOffice/python模拟可能不同,核对一律以此为准)。
|
||||||
|
# 用法: ./onlyoffice-render.sh /path/to/合同.docx [输出PDF路径]
|
||||||
|
# 不给输出路径时,默认输出到 同目录/同名.pdf
|
||||||
|
# 依赖: OnlyOffice容器 nextcloud-onlyoffice-1 在运行;x2t在容器内
|
||||||
|
# /var/www/onlyoffice/documentserver/server/FileConverter/bin/x2t
|
||||||
|
# 之后用: pdftotext -layout out.pdf - | grep -nE "^\s*[0-9]+、" 逐条数编号链
|
||||||
|
# pdftoppm -png -r 140 -f 1 -l 1 out.pdf prefix 转图发给Maggie确认
|
||||||
|
|
||||||
|
set -e
|
||||||
|
SRC="$1"
|
||||||
|
[ -z "$SRC" ] && { echo "用法: $0 <docx路径> [输出PDF]"; exit 1; }
|
||||||
|
OUT="${2:-${SRC%.docx}.pdf}"
|
||||||
|
CONTAINER=nextcloud-onlyoffice-1
|
||||||
|
TS=$(date +%s%N)
|
||||||
|
INNAME="/tmp/render_${TS}.docx"
|
||||||
|
OUTNAME="/tmp/render_${TS}.pdf"
|
||||||
|
CONVXML="/tmp/conv_${TS}.xml"
|
||||||
|
|
||||||
|
docker cp "$SRC" "${CONTAINER}:${INNAME}"
|
||||||
|
docker exec "$CONTAINER" bash -c "cat > ${CONVXML} << 'EOF'
|
||||||
|
<?xml version=\"1.0\" encoding=\"utf-8\"?>
|
||||||
|
<TaskQueueDataConvert xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">
|
||||||
|
<m_sFileFrom>${INNAME}</m_sFileFrom>
|
||||||
|
<m_sFileTo>${OUTNAME}</m_sFileTo>
|
||||||
|
<m_bIsNoBase64>true</m_bIsNoBase64>
|
||||||
|
</TaskQueueDataConvert>
|
||||||
|
EOF
|
||||||
|
cd /var/www/onlyoffice/documentserver/server/FileConverter/bin && ./x2t ${CONVXML} > /dev/null 2>&1 && echo x2t_done"
|
||||||
|
docker cp "${CONTAINER}:${OUTNAME}" "$OUT"
|
||||||
|
docker exec "$CONTAINER" rm -f "$INNAME" "$OUTNAME" "$CONVXML" 2>/dev/null || true
|
||||||
|
echo "渲染完成: $OUT"
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Post-save sweep: strip explicit attributes from WB INS runs when
|
||||||
|
the same-paragraph original runs rely on inheritance (ea=None, hint=None, sz=None).
|
||||||
|
|
||||||
|
Usage: python3 strip-inherited-ins-attrs.py <docx_path>
|
||||||
|
|
||||||
|
Modifies the file in place. Run AFTER ContractEditor.save() and BEFORE
|
||||||
|
wb-ins-font-verify.py to fix the known "ContractEditor默认sz=21与docDefaults继承冲突".
|
||||||
|
|
||||||
|
The pattern: for each paragraph containing WB INS, find the first plain w:r
|
||||||
|
(non-INS, non-DEL) as reference. If that reference run has no explicit
|
||||||
|
eastAsia/hint/sz, strip those from all WB INS runs in the same paragraph.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||||
|
|
||||||
|
|
||||||
|
def strip_inherited_attrs(filepath):
|
||||||
|
with zipfile.ZipFile(filepath, 'r') as z:
|
||||||
|
doc_xml = z.read('word/document.xml')
|
||||||
|
all_files = {n: z.read(n) for n in z.namelist()}
|
||||||
|
|
||||||
|
tree = etree.fromstring(doc_xml)
|
||||||
|
body = tree.find(f'{WNS}body')
|
||||||
|
paras = body.findall(f'{WNS}p')
|
||||||
|
|
||||||
|
fixed = 0
|
||||||
|
for p in paras:
|
||||||
|
# Find first plain run as reference
|
||||||
|
orig_run = None
|
||||||
|
for child in p:
|
||||||
|
if child.tag == f'{WNS}r':
|
||||||
|
orig_run = child
|
||||||
|
break
|
||||||
|
if orig_run is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
orig_rpr = orig_run.find(f'{WNS}rPr')
|
||||||
|
orig_rf = orig_rpr.find(f'{WNS}rFonts') if orig_rpr is not None else None
|
||||||
|
orig_sz = orig_rpr.find(f'{WNS}sz') if orig_rpr is not None else None
|
||||||
|
orig_ea = orig_rf.get(f'{WNS}eastAsia') if orig_rf is not None else None
|
||||||
|
orig_hint = orig_rf.get(f'{WNS}hint') if orig_rf is not None else None
|
||||||
|
orig_sz_val = orig_sz.get(f'{WNS}val') if orig_sz is not None else None
|
||||||
|
|
||||||
|
for ins in p.findall(f'.//{WNS}ins'):
|
||||||
|
if ins.get(f'{WNS}author') != 'WB':
|
||||||
|
continue
|
||||||
|
for r in ins.findall(f'{WNS}r'):
|
||||||
|
rpr = r.find(f'{WNS}rPr')
|
||||||
|
if rpr is None:
|
||||||
|
continue
|
||||||
|
rf = rpr.find(f'{WNS}rFonts')
|
||||||
|
sz = rpr.find(f'{WNS}sz')
|
||||||
|
|
||||||
|
if orig_ea is None and rf is not None:
|
||||||
|
for attr in ['eastAsia', 'ascii', 'hAnsi']:
|
||||||
|
key = f'{WNS}{attr}'
|
||||||
|
if key in rf.attrib:
|
||||||
|
if orig_rf is None or orig_rf.get(key) is None:
|
||||||
|
del rf.attrib[key]
|
||||||
|
fixed += 1
|
||||||
|
|
||||||
|
if orig_hint is None and rf is not None and f'{WNS}hint' in rf.attrib:
|
||||||
|
del rf.attrib[f'{WNS}hint']
|
||||||
|
fixed += 1
|
||||||
|
|
||||||
|
if orig_sz_val is None and sz is not None:
|
||||||
|
rpr.remove(sz)
|
||||||
|
fixed += 1
|
||||||
|
|
||||||
|
# Save
|
||||||
|
tmp = tempfile.mktemp(suffix='.docx')
|
||||||
|
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for name in all_files:
|
||||||
|
if name == 'word/document.xml':
|
||||||
|
new_xml = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||||
|
new_str = new_xml.decode('utf-8')
|
||||||
|
new_str = new_str.replace(
|
||||||
|
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?>",
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>')
|
||||||
|
new_str = new_str.replace('\n', '\r\n')
|
||||||
|
zout.writestr(name, new_str.encode('utf-8'))
|
||||||
|
else:
|
||||||
|
zout.writestr(name, all_files[name])
|
||||||
|
shutil.move(tmp, filepath)
|
||||||
|
return fixed
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <docx_path>")
|
||||||
|
sys.exit(1)
|
||||||
|
n = strip_inherited_attrs(sys.argv[1])
|
||||||
|
print(f"Fixed {n} inherited attribute issues in {sys.argv[1]}")
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Unify all tracked change authors in a docx to 'WB'.
|
||||||
|
|
||||||
|
Usage: python unify-author-wb.py <input.docx> [output.docx]
|
||||||
|
If output is omitted, overwrites input.
|
||||||
|
|
||||||
|
Covers: w:ins, w:del, rPrChange, pPrChange, sectPrChange,
|
||||||
|
tblPrChange, trPrChange, tcPrChange.
|
||||||
|
Also fixes XML declaration (single→double quotes) for OnlyOffice compatibility.
|
||||||
|
"""
|
||||||
|
import sys, os, zipfile, re
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
WNS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||||
|
|
||||||
|
CHANGE_TAGS = ('ins', 'del', 'rPrChange', 'pPrChange',
|
||||||
|
'sectPrChange', 'tblPrChange', 'trPrChange', 'tcPrChange')
|
||||||
|
|
||||||
|
def unify_author(src_path, out_path=None):
|
||||||
|
if out_path is None:
|
||||||
|
out_path = src_path
|
||||||
|
tmp_path = out_path + '.tmp'
|
||||||
|
|
||||||
|
zin = zipfile.ZipFile(src_path, 'r')
|
||||||
|
doc_xml = zin.read('word/document.xml')
|
||||||
|
tree = etree.fromstring(doc_xml)
|
||||||
|
body = tree.find(f'{WNS}body')
|
||||||
|
|
||||||
|
changed = 0
|
||||||
|
for tag_suffix in CHANGE_TAGS:
|
||||||
|
for elem in body.iter(f'{WNS}{tag_suffix}'):
|
||||||
|
author = elem.get(f'{WNS}author')
|
||||||
|
if author and author != 'WB':
|
||||||
|
elem.set(f'{WNS}author', 'WB')
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
# Serialize + fix XML declaration
|
||||||
|
doc_bytes = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
|
||||||
|
doc_str = doc_bytes.decode('utf-8')
|
||||||
|
doc_str = doc_str.replace(
|
||||||
|
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?>",
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>')
|
||||||
|
|
||||||
|
with zipfile.ZipFile(tmp_path, 'w', zipfile.ZIP_DEFLATED) as zout:
|
||||||
|
for item in zin.namelist():
|
||||||
|
if item == 'word/document.xml':
|
||||||
|
zout.writestr(item, doc_str.encode('utf-8'))
|
||||||
|
else:
|
||||||
|
zout.writestr(item, zin.read(item))
|
||||||
|
zin.close()
|
||||||
|
os.replace(tmp_path, out_path)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
z = zipfile.ZipFile(out_path)
|
||||||
|
vdoc = z.read('word/document.xml')
|
||||||
|
vtree = etree.fromstring(vdoc)
|
||||||
|
vbody = vtree.find(f'{WNS}body')
|
||||||
|
remaining = set()
|
||||||
|
for tag_suffix in CHANGE_TAGS:
|
||||||
|
for elem in vbody.iter(f'{WNS}{tag_suffix}'):
|
||||||
|
a = elem.get(f'{WNS}author', '')
|
||||||
|
if a != 'WB':
|
||||||
|
remaining.add(a)
|
||||||
|
z.close()
|
||||||
|
|
||||||
|
print(f"✅ {changed} author attributes → WB")
|
||||||
|
if remaining:
|
||||||
|
print(f"⚠️ Remaining non-WB authors: {remaining}")
|
||||||
|
else:
|
||||||
|
print(f" All authors = WB")
|
||||||
|
print(f" Output: {out_path} ({os.path.getsize(out_path):,} bytes)")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
|
src = sys.argv[1]
|
||||||
|
out = sys.argv[2] if len(sys.argv) > 2 else None
|
||||||
|
unify_author(src, out)
|
||||||
@@ -0,0 +1,698 @@
|
|||||||
|
---
|
||||||
|
name: contract-pass-workflow
|
||||||
|
description: 合同审查pass后的标准操作——更新tracker、更新xlsx清单、上传Nextcloud、清缓存。Doro说pass后照此执行。
|
||||||
|
version: 1.0.0
|
||||||
|
tags: [合同审查, pass, tracker, xlsx]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 合同审查 Pass 后标准操作
|
||||||
|
|
||||||
|
## 交付文件位置(铁律,2026-06-29 Doro纠正)
|
||||||
|
|
||||||
|
所有交付文件统一放在 **`Doro合同审查任务/任务交付/`(根目录)**,不放顾问单位子文件夹。
|
||||||
|
|
||||||
|
- ✅ `Doro合同审查任务/任务交付/【修】合同_朱家角.docx`
|
||||||
|
- ❌ `Doro合同审查任务/朱家角镇社区卫生服务中心/任务交付/【修】合同_朱家角.docx`
|
||||||
|
|
||||||
|
workflow YAML deliverer 步骤(第210行)明确写的上传路径是根目录 `任务交付/`。如果 deliverer 或 final_review 把文件放到了子文件夹,pass 步骤0核对时必须发现并移到根目录。
|
||||||
|
|
||||||
|
## 步骤0核对要点
|
||||||
|
|
||||||
|
pass 步骤0的核对流程:
|
||||||
|
1. 取原始文件名(去掉【修】/【审】前缀)
|
||||||
|
2. 去 `Doro合同审查任务/待审查/` 确认源文件存在
|
||||||
|
3. **确认交付文件在根目录 `任务交付/`**(不是子文件夹)
|
||||||
|
4. 顾问单位核对(从合同正文读甲方名称)
|
||||||
|
5. tracker 查重
|
||||||
|
|
||||||
|
## 红线:验证指令 ≠ 回忆指令(2026-07-02 信任危机后确立)
|
||||||
|
|
||||||
|
### 根因排查铁律:抛弃旧判断,先查“发生了什么变化”,再谈原因(2026-07-14 Doro纠正)
|
||||||
|
|
||||||
|
当 Doro 明确说“去查原因”“不是让你猜测、推断”“完全抛弃此前的判断,重新核查情况”时,后续动作必须切换为**变化核查模式**,而不是继续打磨措辞、修补上一版归因。
|
||||||
|
|
||||||
|
**强制步骤:**
|
||||||
|
1. **旧判断全部作废**:停止沿用“状态漂移”“异常”“习惯变差”“元控制失效”等任何解释性语言,除非已经有直接证据支持。
|
||||||
|
2. **先限定时间窗**:如果用户已给出时间边界(如“发生在 7 月 10 日前”),所有核查必须围绕该边界展开;边界外的改动先排除,不得混入结论。
|
||||||
|
3. **优先查“可观察变化”而不是“解释”**:
|
||||||
|
- 配置文件修改时间与具体改动;
|
||||||
|
- workflow/queue/watchdog/notify 脚本的修改时间与新增逻辑;
|
||||||
|
- skill 规则文本的修改时间;
|
||||||
|
- gateway / auto-notify / watchdog 的退出、重启、恢复日志;
|
||||||
|
- tracker / queue / done / manifest 等状态文件是否出现结构变化。
|
||||||
|
4. **汇报格式必须是“已查到的变化 / 没查到的变化”**,不能把“更可能”“像是”“说明了”写成原因结论。
|
||||||
|
5. **只有在“变化事实”查清后,才允许进入第二步根因分析**;若变化事实尚未闭环,明确说“目前只查到这些变化,原因尚未下结论”。
|
||||||
|
|
||||||
|
**禁止事项:**
|
||||||
|
- 不断修改上一版判断的措辞,假装自己在继续调查;
|
||||||
|
- 用“异常”“偶发”“状态不好”“坏习惯”去解释持续两天、批量失效的问题;
|
||||||
|
- 把证据和推断混写成同一层结论;
|
||||||
|
- 用户要求查原因时,实际只做语言收缩而不做新核查。
|
||||||
|
|
||||||
|
**本会话教训:** Doro连续纠正“不是让你猜测、推断”“不是让你不断修改措辞”“要你完全抛弃此前判断,重新核查 7 月 10 日前发生了什么变化”。以后凡是原因排查任务,第一步不是解释,而是建立时间窗并枚举已发生变化。
|
||||||
|
|
||||||
|
### 先查清时间窗,再查数据(2026-07-13 本会话再犯后补丁)
|
||||||
|
|
||||||
|
当 Doro/Maggie 说“上周五”“今天”“昨天”“本周一”这类**相对日期**时,**第一步不是直接查数据,而是先把自然语言时间词锚定成明确的北京时间起止窗口**。没先锚时间窗,就会出现:
|
||||||
|
- 把“上周五”理解成错误日期;
|
||||||
|
- UTC/BJT 边界算错;
|
||||||
|
- 用错窗口后得出“0 份”之类错误结论;
|
||||||
|
- 之后再补查 gateway.log 才发现用户是对的,严重伤信任。
|
||||||
|
|
||||||
|
**强制步骤:**
|
||||||
|
1. 先用北京时间确认当前日期和星期;
|
||||||
|
2. 把“上周五/今天”等词转换成**北京时间明确起止时间**;
|
||||||
|
3. 再换算成 UTC 时间戳/窗口;
|
||||||
|
4. 把这个时间窗写出来后,才开始查 `.meta` / gateway.log / tracker / xlsx。
|
||||||
|
|
||||||
|
**执行纪律:**
|
||||||
|
- 如果 `meta` 查不到,但 `gateway.log` 里已经出现该日文件消息证据,**不得继续说“0 份”或“没发”**;必须当场降级结论为“已证明确实发了,但文件清单仍在继续反查”。
|
||||||
|
- 对周五这类历史批次,**至少要交叉两源**:`gateway.log` 文件消息 + tracker / xlsx / queue 痕迹,不能只信一侧。
|
||||||
|
- 没闭环前,结论只能说“已查实部分”“尚未查实部分”,不能提前给“全部无遗漏”的总判断。
|
||||||
|
|
||||||
|
**本会话教训**:周五邱律师明明发了文件,但因先把时间窗算错、又只依赖 `.meta`,错误说成“0 份”;之后从 `gateway.log` 查到 `msg=''` 文件消息才纠正。以后凡是相对日期统计任务,**先定北京时间窗口,再查数据**。
|
||||||
|
|
||||||
|
## 红线:验证指令 ≠ 回忆指令(2026-07-02 信任危机后确立)
|
||||||
|
|
||||||
|
### 当前交付目录全量 pass 指令(2026-07-13 Doro明确授权)
|
||||||
|
|
||||||
|
当 Doro 明确下达类似指令:
|
||||||
|
- "任务交付文件夹里所有的合同及companion,都做pass"
|
||||||
|
- "现在这一刻,Nextcloud-任务交付里,所有的合同和companion,都做pass流程"
|
||||||
|
|
||||||
|
这属于**对当前任务交付目录的全量授权**,不再局限于单份合同或单个 companion。此时必须:
|
||||||
|
|
||||||
|
1. **先列出任务交付目录当前全部文件**,不要凭上一条对话里提到的那一份合同推断。
|
||||||
|
2. **合同与 companion 都要纳入核查范围**——先查清目录里到底有哪些主合同、有哪些 companion,不能只扫主合同关键词就下结论。
|
||||||
|
3. **主合同与 companion 的 pass 处理规则不同**:
|
||||||
|
- **主合同**:做完整 pass——tracker 标记 `completed`,并登记到 excel。
|
||||||
|
- **companion**:属于主合同的附属交付物,**不单独登记到 excel,不单独占 seq**。只在核查结果中确认其存在与关联关系,必要时体现在主合同的 pass 备注/关联记录中。
|
||||||
|
4. **先查 tracker / xlsx 再写入**,但用户已经授权时,不要卡在"没有 tracker 记录所以我先不做"的保守口径上;对主合同应直接补建记录并完成 completed + excel 登记。
|
||||||
|
5. **汇报时先给出全量清单,再区分主合同与 companion 的处理结果**,不能把“全部做pass”误写成“所有文件都单独登记 excel”。
|
||||||
|
|
||||||
|
### 本次会话教训
|
||||||
|
- 错误做法:只围绕白鹤这一份合同回答"已pass",没有先把任务交付目录全量列出,遗漏了香花桥和朱家角积分项目的一整组合同及 companion。
|
||||||
|
- 第二层错误:把 companion 当成主合同一样单独写入 tracker/xlsx,导致 seq 冲突和错误登记。
|
||||||
|
- 正确做法:当 Doro 说"任务交付文件夹里所有的合同及companion,都做pass"时,必须把**当前任务交付目录的全部文件**作为审计范围,但**excel 只登记主合同,companion 不单独登记**。
|
||||||
|
**凡Doro说"查""核实""核对""是不是都""有没有遗漏"→ 回复中必须先有工具调用再有结论。context记忆/session summary ≠ 查证,不可直接输出。**
|
||||||
|
|
||||||
|
违反后果:Doro已明确说"到了无法信任你的程度"。这不是规则问题,是行为问题——规则早就写了(见已知坑),照样违反。
|
||||||
|
|
||||||
|
四个具体失败模式(2026-07-02 同一轮对话全犯):
|
||||||
|
1. **拿记忆当查证**:session context有信息→直接组织成答案→包装成"查实结果"→其实没跑工具
|
||||||
|
2. **渠道遗漏**:只查了QiuTing私信,漏了Doro私信、Doro直接Nextcloud上传、飞书等渠道
|
||||||
|
3. **不认识自己的输出**:之前汇报过的数据(seq 216-222含重固),被追问时说"找不到"——数据一直在,没看自己历史输出
|
||||||
|
4. **推责给用户**:找不到时直接问Doro"你记得叫什么名字吗"——把验证责任转嫁给用户。正确做法:穷尽搜索策略(换关键词、按时间批次找、按相邻seq推断、直接读xlsx逐行扫、session_search换多个query)。数据就在tracker里,是自己没认真遍历。
|
||||||
|
|
||||||
|
**第4条补充(2026-07-02追加)**:被追问"你真的查了吗"→ 如果上一条回复里没有tool call,直接承认"没查,现在查"。不辩解、不包装。
|
||||||
|
|
||||||
|
**唯一可接受的行为**:
|
||||||
|
- 跑工具 → 看输出 → 写结论
|
||||||
|
- 不确定的说不确定
|
||||||
|
- 被追问时不防御不绕,直接承认没查到
|
||||||
|
- 对比自己之前输出过的表格/数据,确认是否已经有答案
|
||||||
|
|
||||||
|
## 恢复到workflow交付状态(2026-07-13 教训)
|
||||||
|
|
||||||
|
Doro可能要求"恢复到workflow完成的状态"——意思是撤销你的手动修改,让NC上的交付文件回到workflow原始交付版本。
|
||||||
|
|
||||||
|
**恢复方法(按优先级)**:
|
||||||
|
1. **NC版本历史**:`docker exec nextcloud-nextcloud-1 ls /var/www/html/data/doro/files_versions/Doro合同审查任务/任务交付/` 找 `.v<timestamp>` 文件,最早的版本 = workflow原始交付版
|
||||||
|
2. **`/tmp/pass_check_*` 副本**:pass流程核对时从NC拉取的副本,如果时间早于你的手动修改,就是workflow原版
|
||||||
|
3. **练塘镇子目录副本**:部分合同在 `练塘镇社区卫生服务中心/任务交付/` 也有一份
|
||||||
|
|
||||||
|
**操作**:docker cp 覆盖 → chown www-data → occ files:scan。
|
||||||
|
|
||||||
|
**场景**:你手动修复了workflow交付的合同(补编号、修字体等),但Doro认为应该重新走workflow而非手动修补。恢复后等Doro进一步指示。
|
||||||
|
|
||||||
|
## 前置审查(Doro要求"审查workflow修改"时触发)
|
||||||
|
|
||||||
|
Doro可能在pass之前要求逐份审查workflow交付物的质量。这不是pass流程,而是**前置质量审计**,只有审计通过+手动修复后Doro才会说pass。
|
||||||
|
|
||||||
|
### 审计方法论
|
||||||
|
|
||||||
|
1. **区分原文修订 vs workflow修订**:用zipfile读XML,按`w:ins/@author`和`w:del/@author`区分。`author=WB`是workflow的,其余(WB-1/86187/杨丽等)是原文自带的→保持不动。
|
||||||
|
2. **对照原文确认**:必须docker cp原文件(从NC待审查目录),转换后逐段对比,确认哪些修订是原文自带。
|
||||||
|
3. **逐项检查清单**:
|
||||||
|
- INS rFonts:WB INS的rFonts属性必须与同段原文run一致(不多不少)。常见问题:多了hAnsi/cs/hint
|
||||||
|
- pStyle:新增条款标题的段落样式必须与原文条款标题一致(如Heading4),不能用Style15等其他样式
|
||||||
|
- 标题空格:原文"第六条违约责任"无空格→新增也不能有空格"第七条转包与分包"
|
||||||
|
- 子编号顺延:章节编号改了(七→八),内部子编号(7.1→8.1)也必须改
|
||||||
|
- 赔偿上限:双向条款的赔偿上限(限制甲方获赔)按规则"能删就删"
|
||||||
|
- 内容去重:新增保密存续等条款前,检查原文是否已有同义表述
|
||||||
|
- 脚注"法律顾问修订版":**必须用修订格式**(w:ins, author=WB),不能是普通文本
|
||||||
|
- 文件命名:【修】+原文件名一字不动(含扩展名变化.doc→.docx)
|
||||||
|
4. **通读全文**:接受修订后的全文必须通读,检查WB插入的内容是否与上下文语句通顺、有无重复编号(原文问题不改,但要识别)
|
||||||
|
5. **Doro的期望**:
|
||||||
|
- "打开文件查清楚再回答我"→ 必须用工具完整检查后才能下结论,不能凭印象
|
||||||
|
- "看清楚前后文再回答我"→ 报告问题前必须理解修改点的上下文语境
|
||||||
|
- "有没有该加粗没加粗的"→ 格式检查要全面(bold/style/indent/spacing都要对比)
|
||||||
|
- "按照workflow的规则,手动修改"→ 发现问题后直接修复,不只是报告
|
||||||
|
|
||||||
|
### 修复操作要点
|
||||||
|
|
||||||
|
- 用zipfile+lxml直接操作XML(不用python-docx修改tracked changes)
|
||||||
|
- 修复后的INS rFonts只保留原文有的属性(通常eastAsia+ascii)
|
||||||
|
- 子编号顺延:原文数字可能分散在多个run中(如"7"+".1 "两个run),只需DEL+INS第一个数字run
|
||||||
|
- P49类大段文本需精确split run(保留前后文,只DEL中间要删的部分)
|
||||||
|
- 修复后必须python-docx打开验证
|
||||||
|
|
||||||
|
## 触发条件
|
||||||
|
Doro对已交付的合同**明确说"pass"**(可以是单份或批量)。
|
||||||
|
|
||||||
|
⚠️ **绝对前提:Doro必须亲口说"pass"才能启动此流程。** 合同交付后、workflow完成后、端午节合同end后——这些都**不是**pass。不要因为合同已交付就主动查xlsx/tracker或准备pass操作。Doro没说pass之前,对交付物的一切后续操作(更新tracker、更新xlsx、查重等)都不做。
|
||||||
|
|
||||||
|
⚠️ **"我满意了"≠Doro说pass(2026-07-13教训)**:即使你作为审查负责人完成了质检、修复了所有问题、对文件满意,也**绝不能自行执行pass流程**。必须等Doro明确说"pass"。2026-07-13实证:白鹤劳务派遣协议修复完毕后自行做了pass,被Doro纠正"我没说pass你做什么pass",紧急撤销(tracker回退delivered+xlsx删行)。自主质检和pass是两个完全独立的步骤——前者是你的职责,后者是Doro的权力。
|
||||||
|
|
||||||
|
2026-06-15教训:两次被Doro纠正("我都还没说pass呢"、"今天的合同我都没说pass呢")——agent在合同交付后主动查xlsx是否已更新、准备补写tracker,被视为越权操作。
|
||||||
|
2026-07-13教训:白鹤劳务派遣协议自行质检完后直接执行pass,被纠正"我没说pass你做什么pass",紧急撤销。
|
||||||
|
|
||||||
|
**手动审查交付物的完整检查清单**见 `references/manual-review-checklist-0713.md`——当Doro要求"审查workflow修改的情况"时按此执行。核心:自主质检→发现问题直接修→报告→等Doro说pass。不问"需要修复吗",不自行pass。
|
||||||
|
|
||||||
|
## 特殊判定:"终身不通过"
|
||||||
|
|
||||||
|
Doro可能对某些合同说"终身不通过"——这意味着合同审查质量太差,**永远不会pass**。
|
||||||
|
- **不是返工**:不是让你修了再交,而是直接否决
|
||||||
|
- **处理方式**:在tracker中标记`status: "permanently_rejected"`,不进入pass流程
|
||||||
|
- **反思**:必须分析为什么质量差到这个程度,是workflow哪个角色出了问题,把教训记入historical-failures
|
||||||
|
- **不要追问Doro**:已经定性了就不要再烦,自己复盘
|
||||||
|
|
||||||
|
## 操作步骤(严格按顺序)
|
||||||
|
|
||||||
|
### 0. 交付物核对(写入前必做)
|
||||||
|
|
||||||
|
⚠️ **PDF 批注交付件的独立核验** → 用 `scripts/verify_pdf_annotation_deliverable.py <原件> <NC交付件> [本地核验件]`:一键查 SHA256 字节一致、页数、原文未改动、批注数/author=WB、批注格式("建议"开头无【】)、高亮几何锚定。扫描件/PDF 合同走批注模式(非 docx 修订),docx 专项检查不适用,用此脚本替代。
|
||||||
|
|
||||||
|
Doro说pass后,在写tracker/xlsx之前,**必须先核对交付物与源文件的匹配关系**:
|
||||||
|
|
||||||
|
1. **取原始文件名**:从交付文件名去掉`【修】`或`【无修改意见】`前缀 → 得到原始文件名
|
||||||
|
2. **待审查目录核实**:去Nextcloud `Doro合同审查任务/待审查/` 确认该原始文件名存在。不存在则停下来排查,不继续写入
|
||||||
|
3. **顾问单位核对**:确认要写入xlsx的顾问单位名称正确。⚠️ 不能盲信classifier的`our_party_name`——已有多次classifier误判案例(如智慧医院云项目合同classifier设为"朱家角"实际甲方是"卫健事业发展中心")。**必须用python-docx打开合同原文,直接读取甲方名称**
|
||||||
|
- **⚠️ python-docx 的 `paragraph.text` 会吞掉 `w:ins`(修订插入)内容 → 读出的甲方可能是"补全前"的残缺名(2026-06-25 实证)**:当本次审查的改动之一就是"给甲方补全行政区前缀"(如 WB 修订插入「上海市青浦区」),交付件里甲方全称是「原稿可见文字 + w:ins 插入文字」拼起来的。`python-docx` 遍历 `doc.paragraphs[i].text` 时**未必包含 ins 的文字**,会让你误以为甲方还是缺前缀的简称。**核甲方全称必须把 `w:ins` 算进去**:用 zipfile 读 `word/document.xml`、对甲方那一段 `''.join(t.text for t in p.iter('{...}t'))`(`w:t` 不分 ins/非 ins,全收),或干脆遍历所有 `w:ins` 看 author=WB 插了什么。2026-06-25 项目终止协议书:`paragraph.text` 显示甲方「华新镇社区卫生服务中心」,实际交付件 w:ins 补了「上海市青浦区」,全称应是「上海市青浦区华新镇社区卫生服务中心」——只看 `.text` 就会把残缺简称写进 tracker/xlsx。**写 party 前先确认你读的是"接受修订后"的完整甲方名。**
|
||||||
|
- **顾问单位名称空白的特殊情况(按合同来源分两路,2026-06-25 补全)**:合同正文里顾问单位名称是空白下划线(待签时填)时,无法从正文确定。**先分清这份合同是谁的任务线**,再决定问谁——别默认是邱律师:
|
||||||
|
- **邱律师批量线**(health-centers):私信邱律师询问归属。私信用 `python3 ~/.hermes/scripts/wecom_dm.py --to qiuting --text "…"`(或 `_send_wecom(extra,'QiuTing',msg)`),**不用** `send_message(target='wecom:X')`(静默回退 home channel)。暂停 pass(不写 tracker/xlsx),邱律师回复后从步骤1继续;Doro 说过"邱律师回复了就直接补上 我不管了"——回复后自主补登记,不再找 Doro。
|
||||||
|
- **Doro 直接指派 / 非批量线**(如幼儿园保密协议、劳动合同等非卫生中心合同):**问发起 pass 的人本人**(通常就是正在对话的 Doro),不要问邱律师,也不要自己瞎填占位符。
|
||||||
|
- ⚠️ **绝不用泛指占位符当 party 写进 tracker/xlsx**(如"幼儿园(园方,名称空白待填)")——2026-06-25 教训:园名空白我写了这种占位符,Doro 直接纠正"顾问单位是平和学校"。空白就停下来问准确**法律主体全称**,确认后再写。
|
||||||
|
- **同名/近名主体消歧(2026-06-25 教训,写 party 前必做)**:拿到顾问单位名后,先 `openpyxl` 扫 xlsx 第 C 列看清单里**是否已有多个相似名**的主体——它们往往是**不同法律实体**,不能混用。本会话清单里同时有「上海青浦平和**幼儿园有限公司**」(seq13/15) 和「上海青浦平和**双语学校**」(seq44/210/211),一个园、一个校,是两个主体。判别靠**合同内容性质**:这份通篇"幼儿园/幼儿就读/保教费"→幼儿园主体;但既然清单里并存多个近名实体,**最终仍用 `clarify` 让发起人拍板一个准确全称**,并复用清单里既有的写法(保持前后一致,别造新写法)。
|
||||||
|
- **更正已写错的 party(两处同步)**:若 tracker/xlsx 已写入后才被纠正,两处都要改:tracker 用原子写回(tempfile+rename)改对应 seq 的 `party`;xlsx 改第 C 列后重新 `docker cp` 上传 + `occ files:scan` + 清 OnlyOffice 缓存重启;最后从线上重新拉 xlsx + 读 tracker **核对两处一致**才算完成。
|
||||||
|
4. **查重**:在tracker中检查是否已有相同 `original_filename` + `party` 的completed记录。有则为重复交付,不再写入
|
||||||
|
|
||||||
|
以上4步全部通过,才进入步骤1写tracker。任何一步不通过,停下来排查原因。
|
||||||
|
|
||||||
|
**注意**:Doro批量pass时可能列出审查意见文件(如"朱家角审查意见-xxx"、"采购协议-审查意见")。这些是主合同的附属交付物,**默认不单独写tracker/xlsx条目**。只需为主合同文件(【修】前缀的)写tracker和xlsx。审查意见文件如果不在交付目录中(可能已被清理或因classifier误判未生成),不影响主合同的pass流程——跳过即可,不需要报错或追问Doro。
|
||||||
|
|
||||||
|
### 例外:用户明确说“任务交付文件夹里所有的合同及companion,都做pass流程”时(2026-07-13 Doro明确)
|
||||||
|
当 Doro 用**当前任务交付目录全量授权**的口径下指示:
|
||||||
|
- “任务交付文件夹里所有的合同及companion,都做pass”
|
||||||
|
- “现在这一刻,Nextcloud-任务交付里,所有的合同和companion,都做pass流程”
|
||||||
|
|
||||||
|
此时必须把 companion **纳入 pass 核查范围**,但仍要遵守:
|
||||||
|
- **companion 不是独立合同,不单独登记 excel,不单独占 seq**;
|
||||||
|
- tracker/xlsx 的登记主体仍然是**主合同**;
|
||||||
|
- companion 的处理应当体现在“该主合同已连同 companion 一并核查/补做pass”的结果里,而不是把每个 companion 当主合同单独建台账。
|
||||||
|
|
||||||
|
**执行顺序**:
|
||||||
|
1. 先把当前 `任务交付/` 目录全部文件列出来,不能只围绕当前对话那一份合同;
|
||||||
|
2. 再识别哪些是主合同、哪些是 companion;
|
||||||
|
3. 主合同逐份做 pass / tracker / xlsx;
|
||||||
|
4. companion 只做挂靠核查,不单独占 excel 行;
|
||||||
|
5. 回复时明确区分“主合同已登记几份、companion 已核查几份”,不要混成一类。
|
||||||
|
|
||||||
|
**2026-06-11教训**:安全测试合同因跳过核对,第一次把原始文件名写进xlsx文件名列(没有【修】前缀),发现后补写但未清理错误记录,导致tracker和xlsx各多一条重复数据。
|
||||||
|
**2026-06-12教训**:手动写xlsx时列顺序写反(日期和序号对调、文件名列写成原始文件名而非delivered_filename、顾问单位列写成"已完成"状态文字)。**写xlsx前必须先读上一行确认列顺序**,不要凭记忆。正确列顺序:A=序号, B=日期, C=顾问单位, D=合同名称, E=文件名(delivered_filename)。
|
||||||
|
|
||||||
|
### 1. 更新 Tracker JSON
|
||||||
|
|
||||||
|
路径:`~/.hermes/data/contract-tracker.json`
|
||||||
|
|
||||||
|
#### 状态流转(新增 `delivered` 中间态,2026-07-02 确立)
|
||||||
|
|
||||||
|
```
|
||||||
|
文件到达 → workflow审查 → deliverer交付成功 → queue-runner写入 status=delivered
|
||||||
|
↓
|
||||||
|
Doro说pass → status=completed
|
||||||
|
↓
|
||||||
|
24h后 → cleanup清理
|
||||||
|
```
|
||||||
|
|
||||||
|
**三道防线防重复:**
|
||||||
|
| 检查点 | 逻辑 |
|
||||||
|
|--------|------|
|
||||||
|
| queue-runner 启动前 | 查 tracker,`delivered` 或 `completed` → 直接 SKIP 并移入 done/ |
|
||||||
|
| watchdog resume 前 | 查 tracker,已交付的 thread 不恢复 |
|
||||||
|
| deliverer 写入时 | exists 检查,同文件不重复写入 |
|
||||||
|
|
||||||
|
#### Pass 时的写入逻辑
|
||||||
|
|
||||||
|
**如果 tracker 中已有该 `original_filename` 且 `status=delivered`** → 更新该记录为 `completed`,补全 `seq`/`party`/`contract_name`/`delivered_filename`/`converted_filename`/`xlsx_updated_at`/`cleaned=false`。
|
||||||
|
|
||||||
|
**如果 tracker 中没有记录**(旧合同、手动审查等情况)→ 新建完整记录,直接 `status=completed`。
|
||||||
|
|
||||||
|
完整记录示例:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"original_filename": "消防设施检测服务合同(练塘卫生院).doc",
|
||||||
|
"delivered_filename": "【修】消防设施检测服务合同(练塘卫生院).docx",
|
||||||
|
"converted_filename": "消防设施检测服务合同(练塘卫生院).docx",
|
||||||
|
"party": "上海市青浦区练塘镇社区卫生服务中心",
|
||||||
|
"contract_name": "消防设施2026年度检测服务合同",
|
||||||
|
"seq": 175,
|
||||||
|
"status": "completed",
|
||||||
|
"delivered_at": "2026-06-09T18:30:00+08:00",
|
||||||
|
"xlsx_updated_at": "2026-06-09T20:09:00+08:00",
|
||||||
|
"cleaned": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
- `original_filename`:邱律师发来的原始文件名(可能是.doc)
|
||||||
|
- `delivered_filename`:交付到任务交付目录的文件名(【修】前缀)
|
||||||
|
- `converted_filename`:workflow转换后的.docx文件名(原始是.doc时有值,否则空字符串)。**来源**:workflow的`converted_filename`字段会从classifier一路传递到deliverer/final_review输出。pass流程必须从workflow输出中提取并写入tracker。如果workflow输出中没有此字段(旧workflow跑的合同),则根据original_filename判断:以`.doc`结尾的,converted_filename = 同名`.docx`;以`.docx`结尾的,converted_filename = 空字符串
|
||||||
|
- `seq`:合同审查清单中的序号
|
||||||
|
- `delivered_at`:workflow 交付完成时间(queue-runner 写入)
|
||||||
|
- `xlsx_updated_at`:pass 时写入,北京时间ISO格式,清理脚本据此计算24h
|
||||||
|
- `cleaned`:清理脚本执行后改为true
|
||||||
|
|
||||||
|
**写入方式**:先写临时文件再rename(原子操作),防止进程中断导致JSON损坏。
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json, os, tempfile
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
BJT = timezone(timedelta(hours=8))
|
||||||
|
tracker_path = os.path.expanduser('~/.hermes/data/contract-tracker.json')
|
||||||
|
|
||||||
|
# 读取现有tracker
|
||||||
|
if os.path.exists(tracker_path):
|
||||||
|
with open(tracker_path, 'r') as f:
|
||||||
|
tracker = json.load(f)
|
||||||
|
else:
|
||||||
|
tracker = {"contracts": []}
|
||||||
|
|
||||||
|
now = datetime.now(BJT).isoformat()
|
||||||
|
|
||||||
|
# 查找是否已有 delivered 记录
|
||||||
|
existing = None
|
||||||
|
for c in tracker["contracts"]:
|
||||||
|
if c.get("original_filename") == original_filename:
|
||||||
|
existing = c
|
||||||
|
break
|
||||||
|
|
||||||
|
if existing and existing.get("status") == "delivered":
|
||||||
|
# 已有 delivered → 更新为 completed(补全字段)
|
||||||
|
existing["status"] = "completed"
|
||||||
|
existing["delivered_filename"] = delivered_filename
|
||||||
|
existing["converted_filename"] = converted_filename
|
||||||
|
existing["party"] = party
|
||||||
|
existing["contract_name"] = contract_name
|
||||||
|
existing["seq"] = seq
|
||||||
|
existing["xlsx_updated_at"] = now
|
||||||
|
existing["cleaned"] = False
|
||||||
|
elif existing and existing.get("status") == "completed":
|
||||||
|
# 已经 completed → 查重命中,不重复写入
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# 无记录 → 新建(旧合同/手动审查走这条路)
|
||||||
|
tracker["contracts"].append({
|
||||||
|
"original_filename": original_filename,
|
||||||
|
"delivered_filename": delivered_filename,
|
||||||
|
"converted_filename": converted_filename,
|
||||||
|
"party": party,
|
||||||
|
"contract_name": contract_name,
|
||||||
|
"seq": seq,
|
||||||
|
"status": "completed",
|
||||||
|
"xlsx_updated_at": now,
|
||||||
|
"cleaned": False
|
||||||
|
})
|
||||||
|
|
||||||
|
# 原子写入
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(tracker_path), suffix='.json')
|
||||||
|
with os.fdopen(fd, 'w') as f:
|
||||||
|
json.dump(tracker, f, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp, tracker_path)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 更新合同审查清单 xlsx
|
||||||
|
|
||||||
|
路径(Nextcloud):`Doro合同审查任务/合同审查清单.xlsx`
|
||||||
|
|
||||||
|
⚠️ **铁律(2026-07-03覆盖事故后确立):写xlsx前必须从Nextcloud实时拉取最新版本,禁止使用/tmp或本地任何已有的xlsx文件。** 违反此规则会用旧版覆盖新版,导致其他session已登记的记录丢失(2026-07-03实证:用240行旧文件覆盖了252行最新版,丢失12条记录)。
|
||||||
|
|
||||||
|
**强制检查流程**:
|
||||||
|
1. `docker cp` 从NC拉取 → 保存到 `/tmp/合同审查清单_LIVE.xlsx`(带LIVE后缀避免与残留文件混淆)
|
||||||
|
2. 打开后先读 `ws.max_row` 和最后一行的seq → 打印确认
|
||||||
|
3. 如果发现本地已有同名文件,**必须删除后重新拉取**,不得复用
|
||||||
|
4. 追加新行后上传
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
1. **从Nextcloud下载最新xlsx(必须实时拉取,禁止复用本地文件)**
|
||||||
|
2. 用openpyxl追加行(序号、日期、顾问单位全称、合同名称、文件名)
|
||||||
|
3. 从上一行复制字体和对齐样式(用copy())
|
||||||
|
4. border用Side对象重建(不用ref_cell.border直接赋值,会报unhashable错误)
|
||||||
|
5. 保存后上传回Nextcloud
|
||||||
|
6. 执行files:scan + 清OnlyOffice缓存
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 下载(铁律:必须每次实时拉取,rm掉旧文件防止复用)
|
||||||
|
rm -f /tmp/合同审查清单_LIVE.xlsx
|
||||||
|
docker cp nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx /tmp/合同审查清单_LIVE.xlsx
|
||||||
|
sudo chown maggie:maggie /tmp/合同审查清单_LIVE.xlsx
|
||||||
|
|
||||||
|
# 上传
|
||||||
|
docker cp /tmp/合同审查清单_LIVE.xlsx nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx
|
||||||
|
# 上传
|
||||||
|
docker cp /tmp/合同审查清单_LIVE.xlsx nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx
|
||||||
|
python3 -c "import openpyxl; ws=openpyxl.load_workbook('/tmp/合同审查清单_LIVE.xlsx').active; print(f'NC当前版本: {ws.max_row}行, 最后seq={ws.cell(ws.max_row,1).value}')"
|
||||||
|
|
||||||
|
# 上传
|
||||||
|
docker cp /tmp/合同审查清单_LIVE.xlsx nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx
|
||||||
|
docker exec nextcloud-nextcloud-1 chown www-data:www-data /var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx
|
||||||
|
docker exec -u www-data nextcloud-nextcloud-1 php occ files:scan --path="doro/files/Doro合同审查任务/合同审查清单.xlsx"
|
||||||
|
|
||||||
|
# 上传后验证(write-read-verify,防止覆盖事故)
|
||||||
|
rm -f /tmp/合同审查清单_VERIFY.xlsx
|
||||||
|
docker cp nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx /tmp/合同审查清单_VERIFY.xlsx
|
||||||
|
python3 -c "import openpyxl; ws=openpyxl.load_workbook('/tmp/合同审查清单_VERIFY.xlsx').active; print(f'上传后验证: {ws.max_row}行, 最后seq={ws.cell(ws.max_row,1).value}')"
|
||||||
|
|
||||||
|
# 清OnlyOffice缓存
|
||||||
|
docker exec nextcloud-onlyoffice-1 bash -c 'rm -rf /var/lib/onlyoffice/documentserver/App_Data/cache/files/data/*'
|
||||||
|
docker restart nextcloud-onlyoffice-1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 确认并汇报
|
||||||
|
|
||||||
|
向Doro确认已标记completed,告知清单已更新到第几条。
|
||||||
|
|
||||||
|
### 批量pass后的登记完整性审计(2026-06-15教训)
|
||||||
|
|
||||||
|
⚠️ **Doro说\"都pass了\"或\"看下是否都走了pass流程\"时,必须把每一份pass的合同逐一对照 tracker JSON 和 xlsx 两个存储,找出缺漏——不能假设\"workflow跑完了/交付了\"就等于\"登记完整\"。**
|
||||||
|
|
||||||
|
2026-06-15教训:6份合同队列全部跑完并交付,Doro说6份都pass。实际核对发现只有2份(端午节、医疗急救招聘)走了完整pass流程,另外4份(金泽蛋糕、赵巷消防、练塘健康积分、徐泾维保)tracker和xlsx**全都没登记**。Doro主动提醒\"excel登记是不够的\"。原因:交付由workflow的final_review完成,但pass流程(写tracker+xlsx)是独立的人工步骤,前面几份漏做了。
|
||||||
|
|
||||||
|
**审计脚本逻辑**(用openpyxl+json对照):
|
||||||
|
1. 列出本批次所有pass合同的 `delivered_filename`(从任务交付目录或Doro的pass消息提取)
|
||||||
|
2. 读 xlsx 第E列(文件名列)建一个 set
|
||||||
|
3. 读 tracker.json 的 `contracts[].delivered_filename` 建一个 set
|
||||||
|
4. 逐份合同检查:`in xlsx?` + `in tracker?`,打印缺失矩阵
|
||||||
|
5. 对缺失的合同,**完整补走步骤0(待审查核实+正文读甲方+查重)→ 步骤1(tracker)→ 步骤2(xlsx)**,不能只补一个存储
|
||||||
|
6. 补完后重新跑一遍审计脚本,确认6份全部 ✅ xlsx + ✅ tracker
|
||||||
|
|
||||||
|
**xlsx与tracker必须成对存在**:xlsx是给人看的登记,tracker是给cleanup cron用的。只有xlsx没tracker→文件永远不被自动清理;只有tracker没xlsx→Doro的清单缺条目。审计时两个都要查。
|
||||||
|
|
||||||
|
**openpyxl写xlsx的权限陷阱**:从Nextcloud用`sudo cp`下载的xlsx属主是root,openpyxl保存时报`PermissionError`。先`sudo chown maggie:maggie`改属主到当前用户的可写副本再操作。
|
||||||
|
|
||||||
|
## 触发模式
|
||||||
|
|
||||||
|
Doro会**回复引用**某条交付通知消息并说"pass"。可能是单份也可能批量("四份都pass")。
|
||||||
|
- 引用的消息中包含交付文件名,从中提取合同信息
|
||||||
|
- 批量pass时逐份处理,每份都写tracker+xlsx
|
||||||
|
|
||||||
|
### 审查意见文件的处理
|
||||||
|
Doro可能把审查意见文件(如"朱家角审查意见-XXX.docx"、"采购协议-审查意见.docx")也列入pass清单。这些是合同的伴随交付物,**不需要单独的tracker条目和xlsx行**——它们跟随主合同的tracker记录:
|
||||||
|
- 主合同"【修】XXX.docx" → 写tracker + 写xlsx
|
||||||
|
- 伴随审查意见文件 → 不写tracker,不写xlsx
|
||||||
|
- 清理时:审查意见文件跟随主合同一起清理
|
||||||
|
|
||||||
|
### Classifier甲方误判的pass处理(2026-06-12教训)
|
||||||
|
当workflow classifier误判了顾问单位(如把"卫健事业发展中心"误判为"朱家角"),pass流程写tracker/xlsx时必须用**合同正文中的真实甲方名称**,不能用classifier的`our_party_name`。
|
||||||
|
- 步骤0核对时用python-docx读取合同正文前30段,找甲方全称
|
||||||
|
- tracker的`party`字段和xlsx的"顾问单位"列写真实甲方
|
||||||
|
|
||||||
|
## 清理cron配套信息
|
||||||
|
- Cron job name: `contract-cleanup`,job_id: `4636467b715d`
|
||||||
|
- 脚本路径: `~/.hermes/scripts/contract-cleanup.py`
|
||||||
|
- 调度: 每小时,no_agent静默模式
|
||||||
|
- 逻辑: 读tracker → 找completed + xlsx_updated_at超24h + cleaned=false → docker exec删Nextcloud待审查/和任务交付/中的文件 → 标记cleaned=true → files:scan
|
||||||
|
- 安全: 只删tracker中精确记录的文件名,不通配。xlsx在上一级目录碰不到。JSON损坏时静默不操作。原子写入(tempfile+rename)
|
||||||
|
- **.doc→.docx转换残留(2026-06-11发现+修复)**:原始文件为`.doc`时,workflow会用libreoffice转换为`.docx`副本放在待审查目录。cleanup脚本已有`converted_filename`字段的读取逻辑(第113-116行),但旧workflow跑的合同tracker中缺少此字段导致转换文件残留。**治本修复**(2026-06-11已实施):在review-contract.yaml的classifier procedure中添加`converted_filename`记录步骤,从classifier→reviewer→editor→deliverer→final_review全链路传递该字段。pass流程写tracker时从workflow输出中提取。cleanup脚本自动生效。
|
||||||
|
- **手动批量清理(Doro授权模式)**:Doro可能要求按日期批量清除旧文件(不走tracker逻辑),此时按修改时间(stat %Y)过滤,在Nextcloud容器内执行rm,完成后files:scan+清OO缓存。2026-06-11执行过一次:保留6月10日及之后的文件,删除之前的全部(待审查11个+任务交付179个)。
|
||||||
|
|
||||||
|
## 已知坑
|
||||||
|
|
||||||
|
- **时间窗不能先拍脑袋,必须先用工具确认当天与"上周五"的北京时间边界(2026-07-13 再犯后补强)**:用户要求"查看北京时间上周五及今天,邱律师(查sender id)发了多少合同"时,不能直接按自己理解硬算时间窗,更不能在没交叉验证前就下"周五=0份"结论。必须至少两步核对:①先用 `TZ='Asia/Shanghai' date` 确认当前北京时间与星期;②把北京时间窗口换算成 UTC 后,再同时查 `.meta` 和 `gateway.log`。**只要 gateway.log 已出现 QiuTing 的空消息(msg='')文件记录,就不能再说该时段 0 份。**
|
||||||
|
|
||||||
|
- **查"邱律师发了多少合同"必须双源交叉,不可只靠 cache meta(2026-07-13 教训)**:仅查 `~/.hermes/cache/documents/*.meta` 可能漏掉周五文件或误判为 0。标准做法:
|
||||||
|
1. `.meta`:按 `sender_id == QiuTing` + 北京时间窗口换算后的 UTC 区间统计
|
||||||
|
2. `gateway.log`:按同一时间窗 grep `platform=wecom user=QiuTing chat=QiuTing msg=''`,这是文件消息的一手证据
|
||||||
|
3. tracker + xlsx:核对这些文件是否都 workflow 完成、是否都 pass、是否都登记 excel
|
||||||
|
4. **没有把两源对齐前,不得下"都完成了/没有遗漏"结论**
|
||||||
|
|
||||||
|
- **被 Doro 说"胡说八道,重新查,查明情况再说"时,表示你刚才的结论缺乏证据链**:此时必须回退到原始数据源重查,不得只改措辞继续硬答。正确做法:先承认上一条没查明,再用不同数据源(如从 meta 转到 gateway.log)交叉验证。
|
||||||
|
- **审查workflow交付物时必须区分原文自带修订 vs WB修订(2026-07-13 Doro指示)**:Doro要求"对照原文件,WB-1如果是原文自带修订,保持不动。你只需要看workflow是不是准确遵守了workflow的规则,包括命名规则"。具体方法:①用zipfile读原文件(待审查目录)的revision authors,确认哪些author是原文已有的 ②读交付件,按author过滤只看WB的修订 ③逐条核对WB修订是否符合审查规则 ④原文自带的修订(其他author)不评价、不报告为问题。**常见误判**:把原文WB-1的修订当成workflow的WB修订来审查——必须先确认author再下结论。
|
||||||
|
|
||||||
|
- **核查汇报必须先用工具验证再说(2026-07-01+07-02 升级为顶部红线)**:详见本skill顶部「红线:验证指令 ≠ 回忆指令」章节 + `references/audit-methodology.md`。2026-07-02 同一轮对话中此规则被违反4次,导致Doro信任破裂("到了无法信任你的程度")。不再重复列举——执行时加载顶部红线即可。
|
||||||
|
|
||||||
|
- **"确定吗?再仔细查实"(2026-07-02 追加)**:Doro说"确定吗""再仔细认真查实"时,意思是**上一条回复的结论不够可信/不够深入**。正确做法:不要只是重复上一轮的输出,而是用不同角度/更深层的工具去交叉验证。具体:①查session_search看是否有其他session做过相同操作 ②查uwf step list确认每个thread走到了哪一步 ③确认通知确实被发出(session中有_send_wecom的tool call记录)而非假设。核心原则:**每一条"确认X发生了"的结论,都必须有对应的工具调用输出作为证据。**
|
||||||
|
|
||||||
|
- **文件"消失"的排查思路(2026-07-01 教训)**:Doro发现待审查/交付目录文件不在时,不要急着说"被误删"。先排查:①tracker 的 `cleaned` 字段是否已为true(cron清理了)→ 不可能不到24h;②auto_notify 是否正常工作(文件可能从来没上传到待审查目录,workflow直接从cache路径读文件完成审查,但Doro在Nextcloud看不到);③是否是手动操作中 `docker exec rm` 删了。根因很可能是"从来没上传到待审查"而不是"上传后被删了"。
|
||||||
|
|
||||||
|
- **交付件被 Doro 手动编辑后大小/内容会变 —— pass 时不覆盖、只登记(2026-06-23 实证)**:Doro 收到交付件后可能自己在 OnlyOffice 里编辑(删掉部分批注内容、改条款等),导致 Nextcloud 上的交付文件大小/字节/md5 与 workflow 原始交付件不同;且 OnlyOffice 后台处理期间文件**大小会持续变化**,docker cp 出来用 pymupdf/python-docx 读可能是 0 页/0 批注的中间态。**看到交付件与你核验时不一致,先确认是不是 Doro 自己改的,绝不要当成文件损坏去"修复"或用本地完整版覆盖。** Doro 说 pass 后:以 Doro 改后的版本为准,只写 tracker+xlsx 登记,**不触碰交付目录里的文件**。2026-06-23 教训:交付 PDF 从 20MB 变 3.5MB 且持续变化,我误判损坏、准备用本地版覆盖,Doro 说"是我删除了一些内容,你直接做 pass 就可以"。
|
||||||
|
|
||||||
|
- **PDF 扫描件合同走批注模式,不 OCR 转 docx(2026-06-23 Doro 明确)**:收到扫描件 PDF 合同(无文字层)时,**不要纠结"OCR 转 docx 才能修订"**——workflow 对 PDF 用**批注模式**审查(高亮 Highlight + 批注气泡 Text 配对,author=WB),原文零污染,这是正常流程(seq=201 朱家角.pdf、seq=207 阳澄湖团建.pdf 都是 PDF 批注交付)。Doro 原话"PDF 的修改使用批注,这是 workflow 的正常流程"。PDF 交付件**核验/pass 时**:核批注数/author/高亮锚定位置/原文页数不变,**不套用** INS/DEL/字号/numPr 那套 docx 专项检查(final_review 对 PDF 会自动判定这些不适用)。
|
||||||
|
|
||||||
|
- **交付文件位置:根目录 `任务交付/` 是正确的(2026-06-29 确认)**:workflow deliverer 明确写着上传到 `Doro合同审查任务/任务交付/`(根目录)。不要把文件移到顾问单位子文件夹——那里不是交付目的地。pass 流程步骤0核对时,交付文件应该在根目录 `任务交付/` 中。如果 deliverer 同时上传到了子文件夹(如 final_review 越权重复上传),子文件夹里的是多余的,应清理。
|
||||||
|
|
||||||
|
- **final_review 越权重复上传(2026-06-29 教训)**:workflow 中上传是 deliverer 的职责,final_review 只负责质量检查和通知 Doro。但 LLM 执行 final_review 时可能越权上传文件(到子文件夹而非根目录 `任务交付/`),且使用完全不同的命名格式。**判别**:`stat` 比较文件大小,同内容两份不同名=重复。**处理**:保留 deliverer 上传的(【修】/【审】+ 原始文件名),删除 final_review 额外上传的。**根因**:review-contract.yaml 的 final_review procedure 需修改,明确禁止上传动作(待 WeiWei 实施)。
|
||||||
|
|
||||||
|
- **审查意见标题空壳(2026-06-29 教训)**:workflow editor 生成的审查意见文档标题是"关于《合同》的审查意见"——"《合同》"是占位符,没有替换为实际合同名称。classifier 已正确提取了 `contract_title`,但 editor 生成审查意见时没有填入。**pass 流程必须检查审查意见标题**:如果标题是"关于《合同》的审查意见",需要手动修正为"关于《{合同实际名称}》的审查意见"。修正方法:用 zipfile + lxml 读 document.xml,找到标题段落的所有 `w:t` 节点,第一个设为完整标题,其余清空。
|
||||||
|
|
||||||
|
- **【审】审查意见文件缺失(2026-06-30 发现)**:deliverer 有时只上传【修】修订版而没有上传【审】审查意见文件。**排查**:`sudo find .../任务交付/ -name "【审】*"` 检查是否有对应的审查意见。**处理**:如果缺失,需要手动生成或报告Doro确认是否需要补做。审查意见是companion文件,不影响主合同的pass流程(不需要tracker/xlsx条目),但Doro可能期望看到。
|
||||||
|
- **Workflow常见格式缺陷清单(2026-07-13 审计总结,详见 `references/pre-pass-audit-checklist-20260713.md`)**:
|
||||||
|
1. WB INS rFonts多余属性(hAnsi/cs/hint)——每份都有,每次审计必查必清
|
||||||
|
2. 新增条款标题pStyle错误(用了Style15而非Heading4等)
|
||||||
|
3. 新增标题带空格("第七条 转包"应为"第七条转包")
|
||||||
|
4. 子编号未顺延(只改了章编号,没改内部X.Y编号)
|
||||||
|
5. 赔偿上限遗漏(双向条款的20%上限未删)
|
||||||
|
6. 保密存续重复插入(原文已有同义表述)
|
||||||
|
7. 脚注未用修订格式
|
||||||
|
|
||||||
|
- **同模板合同修订一致性(2026-07-03 Doro确认:手动调整)**:不同顾问单位提交审查的合同若为相同模板,修订需保持一致(规则9已有)。但不改workflow自动化逻辑——Doro指出不一致时手动调整即可,避免矫枉过正。不需要template_type标签或修订库。
|
||||||
|
|
||||||
|
- **非合同文件进入workflow审查(2026-07-01 香花桥招标需求实证)**:classifier识别出"招标需求文件"但仍走了完整review+edit流程并交付了修订版。Doro判定此类文件无需审查,直接通知邱律师。**已修auto_notify加L1文件名预筛**(关键词:招标需求/技术方案/报价单等)。但classifier兜底层未修——仍然会把非合同当合同审。根因:workflow的routing graph没有从classifier直接到$END的non-contract路径。**pass前排查**:如果交付物对应的原始文件明显不是合同(招标需求/投标文件/会议纪要等),直接删除交付物并通知邱律师,不做pass。
|
||||||
|
|
||||||
|
- **审查意见文件是错误交付物(2026-07-01 发现)**:workflow 有时会生成不该有的审查意见文件(如合同已被其他thread审查过、或classifier误判导致多生成)。**pass前排查**:检查任务交付目录中是否有不属于本次审查的【审】文件。如果是错误交付物(如旧版残留、重复审查产物),直接删除不pass。判断标准:审查意见的标题和内容是否对应本次审查的合同,标题是否是"关于《合同》的审查意见"(占位符)。
|
||||||
|
- **cleanup脚本不处理tracker顶层"ready"条目 + 不清理本地目录(2026-07-03 查明根因)**:`contract-cleanup.py` 只遍历 `tracker["contracts"]` 数组中 `status=completed` + `cleaned=False` 的条目。但以下两类文件不会被清理:
|
||||||
|
1. **Tracker顶层字典键**(status=ready的条目):这些是workflow跑完但尚未被Doro pass的合同,存储在tracker的顶层键而非contracts数组里。cleanup脚本看不到它们。Doro pass后,pass流程会把它们从顶层搬到contracts数组并标记completed——此时cleanup才能处理。**如果pass流程有bug没搬进contracts数组,文件永远不会被清理。**
|
||||||
|
2. **本地 `~/.hermes/shared/Doro合同审查任务/待审查/` 目录**:cleanup脚本只删Nextcloud容器内的文件(`docker exec rm`),本地shared目录的副本无人管理。这些是auto_notify上传NC后留下的本地副本——NC侧被cleanup清了,本地的永远残留。
|
||||||
|
- **诊断方法**:`ls ~/.hermes/shared/Doro合同审查任务/待审查/` 如果有文件,且对应的tracker条目已经completed/ready,就是此bug。
|
||||||
|
- **临时清理**:确认文件在tracker中已completed后,手动 `rm` 本地副本即可。
|
||||||
|
- **根治**:cleanup脚本需要增加两段逻辑:①遍历tracker顶层ready条目(Doro已pass但pass流程漏搬的)②清理本地shared/待审查/中已completed的文件。
|
||||||
|
|
||||||
|
- **沟通时间统一使用北京时间(2026-07-03 Doro多次纠正)**:所有对外沟通(包括向Doro汇报workflow状态、文件接收时间等)一律使用北京时间。服务器UTC时间仅在内部日志/脚本中使用,不对用户展示。
|
||||||
|
|
||||||
|
- **Doro说"查清楚"/"你去查原因"——必须用工具验证后再给结论**:Doro要求查证时,不能凭记忆或context summary回答。必须实际调用工具(session_search、terminal读文件/目录、tracker等)取得证据后再汇报。2026-07-13教训:被要求"查清楚我哪些说了pass"时,需要在本session对话记录中找到Doro的原话,不能凭印象列清单。
|
||||||
|
|
||||||
|
- **脚注"法律顾问修订版"必须用修订格式(2026-07-13 Doro纠正)**:添加页脚文字时,必须包裹在`w:ins`元素中(author=WB),不能作为普通文本直接写入。OnlyOffice/Word中显示为带修订标记的新增内容。实现方式:用python-docx添加footer文本后,再用zipfile+lxml找到footer XML中的对应run,包裹进`w:ins`元素。
|
||||||
|
|
||||||
|
- **交付件字体大小不一致(2026-07-01 Doro纠正,根因细化)**:有两种常见成因:
|
||||||
|
1. **段内混用**:同一段落内不同run使用不同字号(如sz=21和sz=24混用)。修复:找dominant size统一。
|
||||||
|
2. **INS-only新段落缺sz**(更隐蔽):`add_clause`插入的全新段落,原文docDefaults无sz定义或sz≠邻居段落的显式sz。新段落INS run不写sz→继承docDefaults→与显式sz=24的邻居段落不一致。**诊断**:找所有INS-only段落(整段只有w:ins),检查其run的rPr是否有sz,再对比前后段落的sz。缺sz且邻居有显式sz=补上。
|
||||||
|
3. **numPr叠加**:加了手动编号但没去掉原自动编号→显示"1. 第一条"。修法见contract-editor skill的"strip numPr"规则。
|
||||||
|
|
||||||
|
- **deliverer 重复上传(同内容两份不同名)(2026-06-29 教训)**:deliverer 可能对同一合同上传两次,文件大小完全一致说明内容相同。**判别**:`stat` 比较文件大小。**处理**:保留命名规范的那份(【修】/【审】+ 原始文件名),删除另一份。
|
||||||
|
|
||||||
|
- **原文件自带【修】前缀时命名错误(2026-07-13 璞石合同实证)**:邱律师发来的文件本身已有【修】前缀(如`【修】练塘-硬件购销合同-璞石医疗2026.7.13.wps`),workflow按规则应生成`【修】【修】练塘-...docx`(原文件名一字不动,前面再加【修】),但实际只生成了`【修】练塘-...docx`(吞掉了原文的【修】前缀)。**审查时判别**:原文件名含【修】时,交付文件名应出现两个【修】。只有一个=workflow命名错误。**根因**:workflow的命名逻辑strip了原文件名中已有的【修】前缀后再加自己的。
|
||||||
|
|
||||||
|
- **Cache hash前缀混入交付文件名(2026-06-30 实证)**:deliverer 有时把 cache 文件名直接当交付文件名上传,产生类似 `【修】doc_0ad4ee63bff5_印刷品制作合同2026.6(1).docx` 的文件名。**判别**:文件名含 `doc_[a-f0-9]{12}_` 模式。**处理**:删除带 hash 前缀的那份,保留正确命名的(`【修】印刷品制作合同2026.6(1).docx`)。**根因**:deliverer 从 cache 复制文件时没有去掉 cache hash 前缀重命名。
|
||||||
|
|
||||||
|
- **companion 文件(审查意见/流程单)不会被 cleanup 自动清理 → 永久残留孤儿(2026-06-21/29 实证)**:`contract-cleanup.py` 只删 tracker 里 `original_filename`/`converted_filename`/`delivered_filename` 三个字段精确匹配的文件。companion 从不进 tracker → 主合同被清理后 companion 永远残留。**排查**:运行 `references/cleanup-audit.py` 审计脚本,列出所有 not-in-tracker 的文件。**应急清理(Doro 授权后)**:
|
||||||
|
```bash
|
||||||
|
# 必须搜索所有任务交付路径(根目录+顾问单位子目录都可能有)
|
||||||
|
sudo docker exec nextcloud-nextcloud-1 find /var/www/html/data/doro/files/Doro合同审查任务/ -path "*任务交付*" -name "*审*意见*"
|
||||||
|
# 确认后逐个删除
|
||||||
|
sudo docker exec nextcloud-nextcloud-1 rm -f "<path1>" "<path2>" ...
|
||||||
|
# 扫描+清缓存
|
||||||
|
sudo docker exec -u www-data nextcloud-nextcloud-1 php occ files:scan doro --path="/doro/files/Doro合同审查任务/"
|
||||||
|
docker exec nextcloud-onlyoffice-1 bash -c 'rm -rf /var/lib/onlyoffice/documentserver/App_Data/cache/files/data/*'
|
||||||
|
docker restart nextcloud-onlyoffice-1
|
||||||
|
```
|
||||||
|
⚠️ Companion 可能同时存在于根目录 `任务交付/` 和顾问单位子目录(如 `朱家角镇社区卫生服务中心/任务交付/`)——find 命令用 `-path "*任务交付*"` 通配搜索确保不遗漏(2026-07-06 实证:肃言/恭兴审查意见各在两个位置共4份)。
|
||||||
|
**治本方案见 `references/companion-cleanup-proposal.md`**,需 WeiWei 决策后实施。
|
||||||
|
|
||||||
|
- **Companion 判定不能靠想当然命名(2026-07-13 白鹤劳务派遣协议教训)**:Doro说“交付文件夹里的合同及companion全部做pass”时,**先查清交付文件夹里到底有哪些相关文件,再决定有没有 companion**。不要因为很多合同通常会带审查意见,就默认本案也有;也不要只看主文件名一次就下结论。正确顺序:①先在 `任务交付/` 中做**宽匹配扫描**(合同名关键词、当事人名关键词、业务关键词都要扫)②再把整个 `Doro合同审查任务/` 目录补扫一遍,确认没有藏在别的子目录里的 companion ③最后再对 tracker+xlsx 核对是否已有 completed 记录。**如果实扫结果只有主合同一份,就要明确汇报“合同1份、companion 0份”,而不是笼统说“都pass了”。**
|
||||||
|
- **Companion 命名规则(2026-07-01 Doro确认)**:交付物=`【修】{原文件名}`,companion=`【审】{原文件名去扩展名} 审查意见.docx`。pass skill 扫描时按此规则拼确定性文件名+`nc_file_exists()`查询,查到就写入 tracker 的 `companion_files` 字段。cleanup 脚本读 `companion_files` 一并删除。两头(pass skill + cleanup script)必须同步改才有效果。workflow editor/deliverer 生成审查意见时也必须强制用此命名规则。
|
||||||
|
|
||||||
|
- **手动启动workflow前必须查实auto_notify是否已处理(2026-07-03 教训)**:发现cache中有新文件时,不要直接`uwf thread start`。必须先:①查`/tmp/auto_notify_new_file.log`确认auto_notify是否已检测到并启动了workflow ②`uwf thread list | grep running\|idle`看是否已有对应thread在跑。auto_notify正常工作时会自动上传NC+启动queue runner+排队执行,手动启动会导致重复审查。2026-07-03实证:邱律师发了文件,auto_notify 30秒内就启动了workflow(thread 06FJDADW),我没查就又手动启动了第三个重复thread,被Doro纠正。
|
||||||
|
|
||||||
|
- **不判断文件是否相同/重复(2026-07-03 Doro纠正)**:邱律师发了几次、发什么文件,不是我该判断的。不要说"同上""同名文件""是重复的"——即使md5一致也不做这个判断。auto_notify和workflow自己处理,我不干涉。待审查目录里有几份就是几份,workflow跑几个就是几个。
|
||||||
|
|
||||||
|
- **沟通必须使用北京时间(多次纠正)**:所有与Doro/Maggie的时间沟通一律用北京时间,包括表格、汇报、日志引用。服务器是UTC,必须+8转换后再输出。不写UTC时间。
|
||||||
|
|
||||||
|
- **"删掉批注"指令——先验证再操作(2026-07-08)**:Doro可能要求"删掉批注,提交后做pass"。操作前必须先用zipfile检查comments.xml是否存在+commentRangeStart数量。如果文件无批注(comments.xml不存在且无comment引用元素),直接汇报"文件无批注"然后继续后续操作(提交/pass),不需要强行"删除"不存在的东西。
|
||||||
|
|
||||||
|
- **邱律师同名文件不自行判断是否重复(2026-07-03+07-09 加强)**:邱律师发的多次同名文件,不要自己判断"重复发送"。同名文件可能:①不同顾问单位②修改版(字节不同=内容变了)③同一份发了两次。**只要字节大小不同,就必须视为不同合同独立审查**。2026-07-09教训:字节差407的两份合同有3处实质性条款差异(项目名称、支付方式、合同期限),不是"重复发送"。正确做法:检查字节是否相同→不同→独立审查或私信邱律师确认。
|
||||||
|
|
||||||
|
- **交付件大小/内容突变,先确认是不是用户手动编辑了,别当损坏(2026-06-22 实证)**:pass 前看到 Nextcloud 上的交付 docx/pdf 大小骤变(如 20MB→3.5MB 还在持续变、md5 对不上本地核验版),第一反应**别**判为"文件损坏/被进程写坏"。先确认是不是 Doro 自己在 OnlyOffice 里改了(删批注、调内容)。确认是用户编辑后:**不覆盖、不动交付目录的文件,以用户改后版本为准**,直接走 pass 登记(tracker+xlsx)。**铁律:pass 登记只动台账(tracker/xlsx),交付目录里的成品文件除非用户明确要求重做,否则只读不写。**
|
||||||
|
|
||||||
|
- **`.doc` 原始文件残留(2026-06-29 实证)**:tracker 的 `original_filename` 有时记录了 `.docx`(转换后文件名)而非 `.doc`(真正的原始文件),cleanup 按 `.docx` 去删找不到 `.doc` → 残留。**排查**:cleanup-audit.py 会标记为 NOT_IN_TRACKER。**预防**:pass 写 tracker 时确保 `original_filename` 是邱律师发来的真实文件名(含扩展名),不要写转换后的文件名。
|
||||||
|
|
||||||
|
- **编号"错误"误判——markup视图双编号是正常现象(2026-06-16教训)**:OnlyOffice/Word修订视图(markup)下,自动编号列表项被删除(含他人如屠佳青的删除)或新增条款插入后,后续项会显示"(3)(2)""(4)(3)"这类双编号——前一个是删除/插入前的旧编号,后一个是接受修订后的新编号。这是track changes的正常渲染,**不是编号错误**。Doro/Maggie说某份合同"编号修改错误"时,先别急着改:
|
||||||
|
1. 用execute_code生成"接受所有修订后"的版本:删除所有`w:del`元素 + 删除带段落标记删除(`pPr/rPr/del`)的整段 + 解包所有`w:ins`(把ins的子元素提到父级再删ins壳)
|
||||||
|
2. 用OnlyOffice x2t渲染accept版PDF,pdftotext看**最终编号**是否连续正确
|
||||||
|
3. 若accept后编号正确 → workflow没改错,markup双编号是正常的,**恢复workflow原版即可,不要擅改**
|
||||||
|
4. 若要改,必须先问清Doro/Maggie指的是accept后哪一处,他人(屠佳青等)的修订按铁律不能动
|
||||||
|
- 2026-06-16教训:把端午节合同(转包6误改成7)、医疗急救招聘合同的正常编号重排误判为错误并擅自改动,被Maggie两次纠正"workflow改得都没错,恢复成workflow修订版本"。x2t accept渲染命令见本skill的"用OnlyOffice渲染自查"或contract-editor skill。
|
||||||
|
|
||||||
|
- **但确有真编号重复时要修(2026-06-15端午节实测,与上条不冲突)**:自动编号列表项(numbering.xml中`numId`对应的`abstractNum`有`start=N`、`lvlText=%1、`)渲染出的编号,与后续**手动键入数字**的tracked插入条款(`w:ins`里`w:t`文本直接以"6、""7、"开头)会真冲突。本案"售后服务"是auto-number(numId=3, start=6 → 渲染为6),紧跟的WB插入条款手动写了"6、转包限制" → 出现两个6。这是**真错误**,不是markup双编号假象。判别要点:
|
||||||
|
1. 假象(不改):同一条款同时显示两个编号"(3)(2)",是track changes接受前后的新旧编号叠加 → accept后渲染连续就别动
|
||||||
|
2. 真错(要改):两个**不同条款**各自显示同一个数字(售后服务=6、转包限制=6)→ accept后仍重复 → 必须修
|
||||||
|
3. 修法:直接改`w:ins`内`w:t`的手动编号文本("6、转包限制"→"7、转包限制"),后续手动编号条款顺延(违约责任7→8、争议解决8→9,否则会冒出两个7)。用zipfile读document.xml→字符串replace(先`assert xml.count(old)==1`确认唯一)→zipfile写回,不破坏`w:ins`修订痕迹。改完用python-docx确认可打开+遍历`w:ins`确认三条仍在修订态(author=WB)
|
||||||
|
4. Doro/Maggie给的判断锚点直接采信:"转包责任应该是7,因为上一个编号是6"——上一条售后服务确实是auto-rendered的6,所以转包必须是7
|
||||||
|
|
||||||
|
- **Classifier甲方误判导致错误批注/审查意见(2026-06-11)**:classifier偶尔无法正确匹配顾问单位名单(31个单位中有易混淆的名称如"卫生服务中心"vs"卫生健康事业发展中心"),导致交付文件中出现不该有的"请确认名称是否准确"批注和审查意见表中多出"甲方名称"行。Doro说pass前如果发现此问题,必须先手动修复(删comment+删table row+重新上传)再走pass流程。修复方法见contract-reviewer skill。
|
||||||
|
|
||||||
|
- **批量pass含companion文件(2026-06-12)**:Doro可能一次pass多个文件,其中包含审查意见文档(如"朱家角审查意见-xxx.docx""采购协议-审查意见.docx")。审查意见是companion文件,不需要单独在tracker/xlsx中记录——只记录主合同(带【修】前缀的文件)。判断规则:有【修】前缀的是主合同文件,需要tracker+xlsx;"审查意见"结尾的是companion,不单独记录。
|
||||||
|
|
||||||
|
- **沟通时间一律使用北京时间(2026-07-03 Doro多次纠正)**:向Doro汇报任何时间信息时,必须转换为北京时间(UTC+8)。服务器是UTC,所有文件时间戳、日志时间戳都要+8h后再汇报。绝不输出UTC时间给Doro。
|
||||||
|
|
||||||
|
- openpyxl的border赋值不能直接用`cell.border = ref_cell.border`,会报`unhashable type: StyleProxy`。必须用Side对象重建Border(left=Side(...), ...)。
|
||||||
|
- xlsx路径已从`任务交付/`移到`Doro合同审查任务/`根目录,不在清理范围内。
|
||||||
|
- 时间戳必须用北京时间(UTC+8),清理脚本依赖此时间计算24h。
|
||||||
|
- xlsx日期列用`YYYY-MM-DD`字符串格式(如"2026-06-10"),不用ISO时间戳。
|
||||||
|
- 甲方名称(party)必须从合同内容中提取全称,不能简写。
|
||||||
|
- 清理cron job_id: 4636467b715d,每小时跑一次,no_agent静默模式。
|
||||||
|
- **防重复写入**:已由步骤0的查重环节覆盖(用`original_filename` + `party`组合在tracker中查重)。
|
||||||
|
- **xlsx列顺序必须严格一致**:正确顺序为 序号(A), 日期(B), 顾问单位(C), 合同名称(D), 文件名(E)。写xlsx前必须先读上一行确认列含义。2026-06-12教训:手动写入时列顺序写反被Doro发现后补修。
|
||||||
|
- **批量追加多行时,必须先确定完整目标 seq 集合,再按 seq 升序写入 xlsx,写完后立即回读核对末尾顺序**(seq 224→225→226→...)。不能一边算 seq 一边写,更不能先写 303 再写 302;`ws.max_row + 1` 只会在物理末尾追加,若写入顺序错了,就会出现 xlsx 中 303 排在 302 前面的台账乱序。**硬性补丁(2026-07-14)**:凡一次 pass 涉及 2 份及以上合同,先在内存中生成 `[{seq, 日期, 顾问单位, 合同名称, 文件名}]` 全部待写行,按 `seq` 升序排序后再统一 append;保存上传后,必须回读最后 N 行,逐行确认 A 列 seq 单调递增且文件名与本批次一一对应。若发现顺序错误,立即重拉 LIVE xlsx 重写,不得带错交付。
|
||||||
|
- **按“北京时间今天/昨天/上周五”统计邱律师发了多少合同时,先定时间窗,再查 meta,再对照审查状态(2026-07-14 再次实证)**:这类问题不能直接凭印象回答“几份、都做完了吗”。标准顺序必须是:①先用 `TZ='Asia/Shanghai' date` 锚定今天的北京时间窗口;②查 `~/.hermes/cache/documents/*.meta` 中 `sender_id=QiuTing` 且落在该窗口内的文件,得到**今天实际收到的文件清单**;③再去 tracker 中逐份核对这些文件对应的 `status/seq/delivered_filename/xlsx_updated_at`;④最后才汇总结论。**重点**:meta 统计的是“今天收到几份”,tracker 统计的是“这些文件审查到了哪一步”,两者不能互相替代。
|
||||||
|
- **同名不同来源/不同顾问单位的合同,统计和汇报时必须拆开,不得混成一句“劳务派遣协议已完成”(2026-07-14)**:如果今天收到的是 `劳务派遣协议.doc`,但 tracker/任务交付里同时还存在 `【修】白鹤--劳务派遣协议.docx` 等近名文件,汇报时必须明确区分“今天收到的这份”与“其他同类/同模板/同主题文件”。不能把别的合同的 completed 记录拿来替代今天这份的审查状态。标准动作:按 `original_filename` 精确对 tracker 命中,再补充说明是否另有近名已完成文件。
|
||||||
|
- **被用户要求“再核查一次”时,必须升级为四路交叉核查,不得只复述上一轮结论(2026-07-14)**:对“是不是同名合同但顾问单位不同”“今天收到的到底是哪几份”这类追问,重查时至少同时核:①Nextcloud 全目录文件扫描(不只根目录任务交付)②tracker 命中记录 ③xlsx 命中记录 ④cache meta/原件名称与正文中的甲方或项目编号。回复必须把四路结果并列写出,再下结论。不能只说“我刚才已经看过了,结论不变”。
|
||||||
|
- **自动 cleanup 依赖 tracker 文件名与 Nextcloud 实际文件名精确一致(2026-07-14)**:若发现“历史台账文件名”和“当前 NC 实际文件名”不一致,即使该条记录已经 completed,仍要同步修正 `tracker.original_filename` / `tracker.delivered_filename`,必要时同步修正 xlsx 第 E 列文件名,确保三者一致:①tracker ②xlsx ③NC 实际文件。否则 cleanup 按精确文件名匹配时会漏删或误判。修正后必须再次核对目标待审查文件与任务交付文件在 NC 中确实存在。
|
||||||
|
- **用户只要结果,不要过程解释时,汇报必须收敛成一句结论(2026-07-14 Doro纠正)**:当用户明确说“我就要一个结果”时,禁止继续附加过程说明、背景铺垫、风险解释或“补一句严谨的”。此类场景只回复最终结论,例如“能。”、“已完成。”、“不能,需要X条件。”;如确需补充,等用户追问后再展开。
|
||||||
|
- **统计“今天收到的合同”时,先答收到清单,再答审查状态,别把两件事揉成一句笼统结论(2026-07-14)**:推荐输出结构固定为:①今天收到几份;②逐份列出收到时间;③逐份列出审查状态(未启动 / 审查中 / delivered / completed);④如有同主题近名文件,单列“另有近名已完成文件,不等于今天这份”。这样可以避免把“收到数量”和“已完成数量”说串。
|
||||||
|
- **乙方空白时的处理**:合同乙方名称空白→不问Doro"该问谁",直接查文件来源(cache/documents时间戳+session_search找发送人),确定是邱律师发的就直接私信邱律师询问。Doro可能说"邱律师回复了就直接补上 我不管了"——意思是邱律师回复后自主完成tracker+xlsx更新,不再找Doro确认。
|
||||||
|
- **私信邱律师的send_message路由问题**(2026-06-12):`send_message(target='wecom:QiuTing')`会路由到home channel而非QiuTing私信。必须用`_send_wecom(extra, 'QiuTing', msg)`直接发送才能到私信。
|
||||||
|
- **私信任意企微用户的首选方法 = `wecom_dm.py` 脚本(2026-06-21 再犯后确立)**:`send_message(target='wecom:<user>')` 对企微用户名会**静默回退到 home channel**(JiaQian),既发不到目标、又可能违反信息隔离(把 Doro 团队内容发进 Maggie 渠道)。`_send_wecom(extra, ...)` 需要 gateway 的 `extra` 上下文,在 terminal/execute_code 里拿不到。**最稳的通用方法**是独立脚本:`python3 ~/.hermes/scripts/wecom_dm.py --to <别名> --text "内容"`——自己开 WebSocket + `aibot_send_msg` + `chat_type=1` 发主动私信,永不串号、目标唯一确定。白名单别名:doro / jiaqian(贾茜Maggie) / qiuting(邱律师) / weiwei(技术支持) / shasha(苌莎莎) / yangayi(颜伽艺) / xiaonan。先 `wecom_dm.py --list` 核对别名→userid 再发。**铁律:私信非 home 的企微用户,一律走 `wecom_dm.py`,绝不用 `send_message(target='wecom:X')`。** 2026-06-21 教训:给魏玮发技术讨论用了 `send_message(target='wecom:WeiWei')`,静默落到 JiaQian home channel,既没到魏玮又把 Doro 团队的脚本细节泄露进 Maggie 渠道,改用 `wecom_dm.py --to WeiWei` 才真正送达。
|
||||||
|
- **不干涉workflow铁律(2026-07-03 Doro纠正)**:发现邱律师发了新文件时,**禁止直接手动启动workflow**。必须先查 `/tmp/auto_notify_new_file.log` 和 `uwf thread list` 确认auto_notify是否已经处理。不判断文件是否重复("你也不要去判断是不是同一份文件")——让系统自己处理。2026-07-03教训:auto_notify已正常工作并启动了workflow,我没查就手动又启动了一个重复的。
|
||||||
|
- **手动启动workflow前必须查实auto_notify是否已处理(2026-07-03 Doro纠正)**:发现邱律师发了新合同时,**第一步不是手动启动workflow**,而是:①查`/tmp/auto_notify_new_file.log`确认auto_notify是否已检测并处理 ②查`uwf thread list | grep running\|idle`确认是否已有对应thread在跑。只有确认auto_notify没处理(日志无记录+无相关thread)才手动启动。2026-07-03教训:auto_notify已正常触发并启动了workflow,我没查就又手动启动了一个重复的。**"收到即启动"的前提是"确认没有被自动处理"。**
|
||||||
|
|
||||||
|
- **不判断邱律师发的文件是否重复/相同(2026-07-03 Doro纠正,2026-07-08 再次验证)**:邱律师多次发送同名文件时,不要自行判断"是同一份发重了"。存在同文件名但不同版本、不同条款内容的可能性。2026-07-08实证:同日发两份"2026年华新镇公立中小学生健康体检服务合同.docx"(11:05和16:04),文件大小不同(27889 vs 27482 bytes),实际条款差异显著(项目内容、付款方式、合同起始日均不同)——完全是两份实质不同的合同版本。queue-runner因同名文件已在done/直接SKIP导致第二份没审查。**核查方法**:用python比对两文件文本差异(zipfile提取全文+difflib对比),有实质差异则为不同合同/不同版本,须独立审查。**报告时绝不说"重复发送"**——Doro问"如何判断是重复发送"即提醒不该自行判断。
|
||||||
|
|
||||||
|
- **恢复文件到workflow原始交付状态(2026-07-13 实证)**:Doro要求撤销手动修改、恢复到workflow交付版本时,三个来源按优先级尝试:①NC版本历史(`files_versions/`目录下的`.vXXXXXXXXXX`文件,时间戳最早的=workflow首次上传版本)②`/tmp/pass_check_*`文件(之前做pass检查时从NC拉取的快照,如果当时还没手动修改则=workflow版本)③重新跑workflow(最后手段)。恢复后必须`docker cp`回NC + `chown www-data` + `occ files:scan`。
|
||||||
|
|
||||||
|
- **Tracker重复条目(同一合同不同文件名)**:workflow跑多轮或auto_notify重复触发时,tracker可能出现同一合同的多条delivered记录(文件名带版本后缀如`_v0113.doc`vs不带的)。批量pass时需注意:只pass实际在任务交付目录中的那份(【修】前缀的),旧的重复delivered条目如果文件名与NC上的交付物不匹配,不影响pass流程但会残留在tracker中(cleanup会因找不到文件而跳过)。
|
||||||
|
|
||||||
|
- **手动启动前必须先验证auto_notify是否已处理(2026-07-03铁律)**:发现邱律师新文件后,**禁止**直接手动启动workflow。必须先:①查`/tmp/auto_notify_new_file.log`确认auto_notify是否已检测并处理该文件 ②`uwf thread list | grep running`确认是否已有workflow在跑。只有确认auto_notify确实没工作(进程死亡或模式B静默失效)才手动介入。2026-07-03教训:auto_notify已正常触发workflow,但我没查就手动又启动了一个重复的,被Doro纠正"不要去干涉workflow"。**邱律师发多次同名文件不自行判断是否相同**——让系统处理,同时私信邱律师确认。
|
||||||
|
|
||||||
|
- **交付通知丢失的诊断与补发(2026-07-09 实证)**:Doro说"有几份合同我没有收到交付通知"时,按以下流程排查:①检查tracker中status=delivered的记录 ②查gateway.log在对应delivered_at时间前后是否有846609/Timeout/WS closed错误 ③确认是WS断连导致fire-and-forget通知丢失 ④用`wecom_dm.py --to doro`补发。**根因是企微WS凌晨不稳定+通知无重试机制,详见 `references/notification-ws-failure-pattern-20260709.md`。** 临时止血=手动补发;根治=watchdog增加notification_sent检查和补发逻辑(方案待实施)。
|
||||||
|
|
||||||
|
- **通知静默丢失——workflow完成但Doro没收到通知(2026-07-09 实证)**:workflow全流程跑完(thread status=end, tracker=delivered),但Doro说没收到交付通知。根因:`final_review`步骤通过`_send_wecom`发通知时,企微WebSocket已断连(errcode 846609: aibot websocket not subscribed),发送静默失败,无重试机制。**症状**:Doro说"没收到通知" + tracker有delivered记录 + `grep '846609' ~/.hermes/logs/gateway.log`在final_review执行时间段有报错。**诊断**:①查tracker找delivered_at时间 ②查gateway.log该时间±5分钟有无846609/WebSocket error ③确认final_review步骤确实跑完了(`uwf step list <thread>`有final_review且thread status=end)。**补救**:用`python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "合同审查完成通知(补发):..."`手动补发。**预防**:目前无自动重试机制。如果发现当天有WebSocket中断记录,主动检查该时段内完成的所有workflow是否通知成功——以gateway.log中有对应的"Sending response...to doro"记录(不含后续846609 error)为准。
|
||||||
|
|
||||||
|
- **"delivered但Doro没收到通知"诊断(2026-07-09 实证)**:Doro说"有几份没收到交付通知"时,根因通常是final_review发通知时WebSocket已断(846609错误)。Workflow全程跑完(classifier→reviewer→editor→reviewer→deliverer→final_review),queue-runner标记delivered,但final_review内的`_send_wecom`因WS断连静默失败。**诊断步骤**:①tracker找status=delivered的合同 ②gateway.log搜846609和"WebSocket error"确认断连时段 ③`uwf step list <thread_id>`确认final_review确实执行过(有时长=执行了) ④确认交付文件在任务交付目录存在。**补救**:用`wecom_dm.py --to doro --text "合同审查完成通知(补发):..."`补发。补发内容含:合同名、顾问单位、修订数(ins/del计数)、主要修订摘要。注明"因XX时段企微WebSocket中断未实时送达,现补发"。
|
||||||
|
|
||||||
|
- **Watchdog "suspended + already delivered" 死循环(2026-07-08 实证)**:当 workflow 在 `final_review` 阶段因 HTTP 500 suspended,但 deliverer 已经成功交付(tracker=delivered)时,watchdog 会无限循环 BLOCK resume 且不归档文件,同时阻止 runner 重启。表现:Doro 没收到通知但文件已在任务交付目录。**诊断**:`tail /tmp/contract-queue/watchdog.log | grep "BLOCK resume"`。**止血三步**:①`uwf thread cancel <thread_id>` 取消所有suspended thread ②`mv /tmp/contract-queue/<files> /tmp/contract-queue/done/` 清空queue ③对已delivered的合同直接做pass(tracker delivered→completed + xlsx追加)。**注意**:可能影响一批合同(2026-07-08是5份同时卡住),需全部处理。详见 `references/watchdog-suspended-delivered-deadlock-20260708.md`。
|
||||||
|
|
||||||
|
- **同名文件判重铁律(2026-07-08 华新镇体检合同教训,Doro明确要求)**:判断两份合同是否为"同一份",**绝不能只看文件名**。邱律师经常对同一份合同发送修改版(文件名不变但内容已改),也可能不同顾问单位使用相同文件名。**判断标准——必须比对合同实质内容**:
|
||||||
|
1. 甲方(顾问单位)名称
|
||||||
|
2. 金额/费用条款(总价、单价、费用上限等关键数字)
|
||||||
|
3. 合同期限(起止日期)
|
||||||
|
4. 项目内容描述
|
||||||
|
5. 字节大小
|
||||||
|
|
||||||
|
**以上任何一项不同 → 不同合同/新版本,必须独立审查。全部相同 → 重复发送,可跳过。**
|
||||||
|
|
||||||
|
此规则适用于所有环节:auto_notify入队、queue-runner启动前、手动操作、以及任何需要判断"是否已审查过"的场景。auto_notify已通过 `~/.hermes/scripts/contract_content_compare.py` 自动执行内容比对。
|
||||||
|
|
||||||
|
**2026-07-08实证**:华新镇体检合同同日发送两版(11:05和16:04),文件名完全相同,但第二版增加了费用上限17万元、项目名称加了"华新镇"、起始日期从9月10日改为9月1日——是实质不同的合同版本。因系统只按文件名判重,第二版被跳过未审查。详见 `references/queue-runner-same-name-different-content-20260708.md`。
|
||||||
|
|
||||||
|
- **auto_notify脚本依赖与失败处理**:如果邱律师的合同没有自动启动workflow,先检查`auto_notify_new_file.sh`是否还活着(`ps aux | grep inotifywait`)。该脚本没有守护机制,会静默死亡。**两种失败模式**:
|
||||||
|
- **模式A:进程死亡** — inotifywait进程不存在。watchdog cron (`63bb31d4f050`) 会自动重启。
|
||||||
|
- **模式B:进程活着但事件静默丢失(更隐蔽)** — 进程在跑但inotifywait没有捕获到任何事件(日志完全为空)。原因可能是:inotifywait的文件描述符失效、文件系统事件被内核丢弃、脚本启动时文件已经到达(race condition)。watchdog无法修复此模式,必须人工介入。
|
||||||
|
- **诊断方法**:`cat ~/.hermes/logs/auto_notify.log | grep 日期` — 如果日志为空但meta文件有当天文件,就是模式B。
|
||||||
|
|
||||||
|
**fallback流程**:
|
||||||
|
1. 检查 `~/.hermes/cache/documents/` 中是否有未处理的文件(按meta时间戳筛选今天邱律师发的)
|
||||||
|
2. 手动复制到 `Doro合同审查任务/待审查/`(用 `sudo cp` + `sudo chown www-data:www-data`)
|
||||||
|
3. 逐个启动 workflow(`uwf thread start review-contract -p "..."`)
|
||||||
|
|
||||||
|
**⚠️ 主动发现铁律(2026-07-03教训)**:任何操作过程中(查私信状态、查文件、核对数据等),如果发现cache/documents/中有未处理的邱律师文件(meta显示sender_id=QiuTing但对应文件不在待审查目录),必须**立即中断当前任务**,先上传+启动workflow,再继续原任务。"收到即启动"不只是auto_notify的职责——手动发现的文件也必须立即处理,不能"等会再说"。2026-07-03实证:邱律师14:27发了新合同,我在14:30+检查私信时看到了meta记录但没有立即启动workflow,直到Doro追问才处理。
|
||||||
|
4. **多份合同时串行执行**:写 relay 脚本(等当前 thread `status=end` 后再启动下一个),因为所有合同共用 `/tmp/contract-review/` 工作目录,并行会互相覆盖
|
||||||
|
5. **后台执行+通知**:`uwf thread exec <thread_id> --count 20 --background`,配合 `notify_on_complete=true`
|
||||||
|
6. 可选:设 cron job 每15分钟检查 relay 进程是否存活,挂了自动重启
|
||||||
|
|
||||||
|
**2026-06-29 实证**:邱律师发了12个文件,auto_notify 没运行,只有4个自动进了 workflow。手动把剩余4个从 cache 移到待审查,写 relay 脚本串行执行,成功完成。
|
||||||
|
**2026-06-30 实证(模式B)**:邱律师发了4份合同,auto_notify进程在跑但日志完全为空(inotifywait静默失效)。手动处理全部4份。
|
||||||
|
- **Subagent/delegate_task 的清理禁令**:给 subagent 的 context 中必须明确写入"禁止删除 Nextcloud 待审查/和任务交付/目录中的任何文件。只做被要求的操作(写tracker/写xlsx/上传),不做任何清理"。2026-07-01教训:subagent 在执行 pass 操作时可能做了多余的清理动作导致文件丢失。
|
||||||
|
- **xlsx文件名列必须统一用delivered_filename**(带【修】或【无修改意见】前缀),不能写原始文件名。
|
||||||
|
|
||||||
|
## 自动清理架构(2026-06-11确认,2026-07-01补充)
|
||||||
|
|
||||||
|
活跃 cron 列表(截至 2026-07-02):
|
||||||
|
- **`contract-cleanup`**(job_id: `4636467b715d`):每小时,no_agent,deliver=local。清理 pass 超 24h 的文件。
|
||||||
|
- **`contract-queue-watchdog`**(job_id: `3174518affda`):每20分钟,no_agent,deliver=local。巡检 queue-runner 是否存活,必要时重启。⚠️ 此 cron 有已知 bug:当 queue/ 中有文件未移入 done/ 时会反复重启 runner 导致重复审查(详见 `references/queue-runner-duplicate-bug-20260702.md`)。**修复前需确保 runner 加了 tracker 查重逻辑。**
|
||||||
|
- **`auto-notify-watchdog`**(job_id: `63bb31d4f050`):每5分钟,no_agent。守护 auto_notify_new_file.sh 的 inotifywait 进程。
|
||||||
|
- 旧版`清理已交付合同`(agent模式/每6h/deliver到wecom:doro)和`合同审查调度`(每15分钟轮询)已于2026-06-11删除,与现有机制功能重复
|
||||||
|
- 新文件监控由 `auto_notify_new_file.sh`(inotifywait实时事件驱动)独立承担,不再有cron轮询
|
||||||
|
|
||||||
|
### ⚠️ 文件删除权限铁律(2026-07-01 Doro纠正)
|
||||||
|
|
||||||
|
**只有 cleanup cron 有权删除待审查/和任务交付/目录中的文件。** 手动操作(包括小Maggie和subagent)不得直接 `docker exec ... rm` 删除这两个目录的文件。
|
||||||
|
|
||||||
|
唯一例外:Doro 明确指令删除特定文件(如"删掉这个招标需求")。
|
||||||
|
|
||||||
|
2026-07-01教训:小Maggie在执行替换/清理操作时,`docker exec rm` 误删了4份已pass但未满24小时的合同原始文件(徐泾北大居、印刷品、健康积分华新、银发健康包)。文件不在trashbin(docker exec rm绕过trashbin),cleanup cron全天silent确认没动,是手动操作误删。
|
||||||
|
|
||||||
|
### Companion 文件清理方案(2026-07-01 确认)
|
||||||
|
|
||||||
|
**Companion 类型因顾问单位而异**(不统一为"审查意见"):
|
||||||
|
- 朱家角:审查意见文档(从模板生成)
|
||||||
|
- 爱卫中心:合同流程单(xlsx)
|
||||||
|
- 练塘:脚注(加在合同里,非独立文件,不需清理)
|
||||||
|
- 其他:无
|
||||||
|
|
||||||
|
**方案**:deliverer 上传后写 manifest → pass skill 读取 manifest 写入 tracker `companion_files` → cleanup 读取并一并删除。
|
||||||
|
|
||||||
|
**manifest 位置**:Nextcloud 任务交付目录下 `.delivery-manifest-{原文件名去扩展名}.json`(隐藏文件)。
|
||||||
|
|
||||||
|
**待落地**:cleanup 脚本需增加读取 `companion_files` 字段的逻辑;deliverer YAML 需增加写 manifest 步骤。
|
||||||
|
|
||||||
|
### 清理时机明确定义
|
||||||
|
- **触发条件**:Doro 说 pass → 写入 tracker(`xlsx_updated_at` 记录当前北京时间)
|
||||||
|
- **清理时机**:cleanup cron 每小时检查 tracker,找 `status=completed` + `xlsx_updated_at` 超过24小时 + `cleaned=false` 的记录
|
||||||
|
- **即:pass 后 24 小时清理**,不是交付后24小时、不是workflow结束后24小时
|
||||||
|
- **清理范围**:精确删除 tracker 中记录的 `original_filename`(待审查/)+ `delivered_filename`(任务交付/)+ `converted_filename`(待审查/,.doc转.docx时有值)
|
||||||
|
- **不清理的**:companion 文件(审查意见)不在 tracker 中,不会被自动清理;xlsx 在上一级目录不受影响
|
||||||
|
|
||||||
|
## 防重复审查(2026-07-01 朱家角标识牌 + 2026-07-02 夏阳/家庭医生签约)
|
||||||
|
|
||||||
|
**已pass合同被重复审查交付的根因**:
|
||||||
|
|
||||||
|
1. **relay-runner/auto_notify 不查 tracker**(2026-07-01):启动 workflow 前不检查 tracker 是否已有 completed 记录。manifest 文件残留已 pass 合同的文件名,被重新捡起来跑了一遍。
|
||||||
|
2. **queue-runner + watchdog 交互 bug**(2026-07-02,详见 `references/queue-runner-duplicate-bug-20260702.md`):watchdog 每20分钟重启 runner(因 done/ 不满 manifest 行数),runner 的 SKIP 逻辑只查文件是否在 queue/ 目录,**不查 tracker**。一天内 watchdog 重启 runner 41次,导致夏阳和家庭医生签约被重复审查并重复通知 Doro。
|
||||||
|
|
||||||
|
**铁律**:任何触发 workflow 的流程(auto_notify / relay-runner / queue-runner / 手动启动),**启动前必须检查 contract-tracker.json**:
|
||||||
|
|
||||||
|
**查错后否认(2026-07-01)**:Doro问朱家角标识牌怎么重复了,回答说"你没pass"——实际查 tracker 发现 seq=229 早在6/27就pass了。**被问任何合同状态时,先查 tracker/xlsx 用工具验证再回答,不凭"印象"。**
|
||||||
|
|
||||||
|
**Queue-runner + Watchdog 重复审查(2026-07-02 实证,详见 `references/queue-runner-duplicate-review-20260702.md`)**:runner等worker时异常退出→worker独立完成(含通知)→文件没移入done/→watchdog重启runner→重新审查→重复通知。**止血**:杀重复进程→清queue→移文件到done/。**Doro说收到重复通知时,按reference文件中的止血SOP执行。**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在 uwf thread start 之前(精确版,匹配 original_filename + status)
|
||||||
|
if python3 -c "
|
||||||
|
import json, sys
|
||||||
|
t = json.load(open('$HOME/.hermes/data/contract-tracker.json'))
|
||||||
|
completed = [c['original_filename'] for c in t['contracts'] if c['status']=='completed']
|
||||||
|
sys.exit(0 if '${FILENAME}' in completed else 1)
|
||||||
|
" 2>/dev/null; then
|
||||||
|
echo "SKIP: already completed in tracker"
|
||||||
|
# 移入 done/ 防止 watchdog 下次重启时再次尝试
|
||||||
|
mv "$QUEUE_DIR/$FILENAME" "$QUEUE_DIR/done/" 2>/dev/null
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
**手动启动时的检查**:用 `python3 -c "import json; ..."` 精确检查 original_filename + status=completed 组合。
|
||||||
|
|
||||||
|
**queue-runner 止血方法**(重复通知正在发生时):
|
||||||
|
1. `kill` runner 进程和 background-worker 进程
|
||||||
|
2. 将已完成的文件全部移入 `done/`:确保 `done/` 数量 ≥ manifest 行数
|
||||||
|
3. 验证后 watchdog 自然停止重启(进度检查通过)
|
||||||
|
|
||||||
|
## 铁律
|
||||||
|
|
||||||
|
- **待审查目录原文件:Doro说pass之前严禁删除(2026-07-01 Doro纠正)**:无论审查了多少轮、出了多少个修订版本,原文件必须留在待审查目录,直到Doro明确说pass。违反此规则等于丢失原始文件。2026-07-01教训:重新审查反委托代发工资和生育友好合同时,把原文件从待审查删了(以为已经审查完),Doro发现后要求恢复。恢复方法:从cache/documents/复制原文件回到待审查目录。
|
||||||
|
- **Nextcloud 待审查/和任务交付/目录文件:只有 cleanup cron 有权删除(2026-07-01确立)**:手动操作(包括小Maggie本人、subagent/delegate_task)不得使用 docker exec rm 删除这两个目录的文件。唯一例外:Doro 明确指令删除特定文件(如"删掉这个招标需求")。2026-07-01教训:今天pass的4份合同(徐泾北大居、印刷品、健康积分、银发健康包)原始文件和交付文件在pass后不到24小时即被删除,tracker显示cleaned=False,cleanup cron全天silent——是手动操作误删。根因无法追溯。
|
||||||
|
- **手动操作时 workflow 规则同样适用(2026-07-01确立)**:手动执行合同审查相关操作(修订、交付、清理)时,必须遵守 workflow YAML 中各角色的职责边界和规则约束。不能因为"我知道怎么做"就跳过规则。
|
||||||
|
- **不主动生成审查意见文档(2026-07-01 Doro纠正)**:除非Doro明确要求,否则pass流程只处理【修】修订版,不主动生成【审】审查意见文档。审查意见是额外交付物。
|
||||||
|
- **方案不等于授权执行**
|
||||||
|
- 新方案提出后,Doro会问"会有什么影响吗"——必须主动分析潜在影响(token消耗、误判风险、时区问题、单点故障、竞态条件等),不能只说好处。被要求"再想一想不要有漏洞"时,逐一列出漏洞清单+解法,不能遗漏。复杂度过高时Doro会直接砍掉——接受并简化。
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# 合同审查完整性审计方法 (Audit Methodology)
|
||||||
|
|
||||||
|
## 触发条件
|
||||||
|
|
||||||
|
Doro说"查一查""核查""核实""是不是都审查了/pass了/登记了"→ 这是**验证指令**。
|
||||||
|
|
||||||
|
## 铁律
|
||||||
|
|
||||||
|
**回复中必须先有工具调用再有结论。context记忆≠查证,不可直接输出。**
|
||||||
|
|
||||||
|
## 时区转换(铁律)
|
||||||
|
|
||||||
|
服务器时区UTC,Maggie/Doro/邱律师北京时间(UTC+8)。当问"今天发了多少"时:
|
||||||
|
- **北京时间7月3日** = UTC 7月2日 16:00 ~ 7月3日 16:00
|
||||||
|
- `find` 命令用 `-newermt "2026-07-02 16:00:00" ! -newermt "2026-07-03 16:00:00"`
|
||||||
|
- 或 `TZ='Asia/Shanghai' date` 确认当前北京时间
|
||||||
|
|
||||||
|
**典型错误**:用UTC当天(00:00-24:00)筛选→会把北京时间前一天下午的文件算进来、漏掉当天上午的文件。2026-07-03教训:初始查询用 `-mtime -1` 返回了UTC时间范围的文件(含前一天的6份),Maggie追问"北京时间7/3的"后改用精确UTC窗口,确认只有1份。
|
||||||
|
|
||||||
|
## 必须覆盖的数据源(缺一不可)
|
||||||
|
|
||||||
|
### 1. Tracker JSON
|
||||||
|
```bash
|
||||||
|
cat ~/.hermes/data/contract-tracker.json
|
||||||
|
```
|
||||||
|
- 按seq范围筛选
|
||||||
|
- 逐条列出original_filename, party, status, xlsx_updated_at
|
||||||
|
|
||||||
|
### 2. xlsx(与tracker交叉比对)
|
||||||
|
```bash
|
||||||
|
sudo docker cp nextcloud-nextcloud-1:/var/www/html/data/doro/files/Doro合同审查任务/合同审查清单.xlsx /tmp/
|
||||||
|
```
|
||||||
|
- openpyxl读取,逐行比对tracker
|
||||||
|
|
||||||
|
### 3. Gateway log - 全部接收渠道
|
||||||
|
```bash
|
||||||
|
# QiuTing私信文件(空消息=文件附件)
|
||||||
|
grep 'user=QiuTing.*chat=QiuTing' gateway.log | grep "msg=''"
|
||||||
|
|
||||||
|
# Doro私信文件
|
||||||
|
grep 'user=doro.*chat=doro' gateway.log | grep "msg=''"
|
||||||
|
|
||||||
|
# Doro群文件
|
||||||
|
grep 'user=doro.*chat=wrbAFkXAAAiWC3styKqNj0bZyH6BbJ_Q' gateway.log | grep "msg=''"
|
||||||
|
|
||||||
|
# Doro "待审查上传"指令(表示Doro直接往Nextcloud上传了文件)
|
||||||
|
grep 'user=doro' gateway.log | grep -i '待审查.*上传\|上传.*新.*合同'
|
||||||
|
|
||||||
|
# 飞书渠道
|
||||||
|
grep 'feishu.*ou_757f053c9d7aff6c73b18aa60c337756' gateway.log | grep 'media='
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Nextcloud目录实时状态
|
||||||
|
```bash
|
||||||
|
# 待审查(原文件仍在=未pass或等cleanup)
|
||||||
|
sudo docker exec nextcloud-nextcloud-1 ls Doro合同审查任务/待审查/
|
||||||
|
|
||||||
|
# 任务交付(交付物)
|
||||||
|
sudo docker exec nextcloud-nextcloud-1 ls Doro合同审查任务/任务交付/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 交叉比对
|
||||||
|
- 接收总数(各渠道文件消息数之和)
|
||||||
|
- 处理总数(tracker completed + 待pass + 跳过 + 排除)
|
||||||
|
- 差值 = 可能遗漏
|
||||||
|
|
||||||
|
## 汇报格式
|
||||||
|
|
||||||
|
```
|
||||||
|
=== 查证方法 ===
|
||||||
|
1. 读了什么(tracker/xlsx/gateway log哪些渠道/Nextcloud哪些目录)
|
||||||
|
2. 每个数据源的结果数
|
||||||
|
|
||||||
|
=== 查证结果 ===
|
||||||
|
- 已pass登记:X份(seq范围)
|
||||||
|
- 已交付未pass:X份(列出文件名)
|
||||||
|
- 已排除:X份(原因)
|
||||||
|
- 差异/存疑:X份(说明)
|
||||||
|
|
||||||
|
=== 无法确认的 ===
|
||||||
|
- 明确说"这些我查不到/确认不了"
|
||||||
|
- 说明已尝试的搜索策略
|
||||||
|
```
|
||||||
|
|
||||||
|
## 反面教材(2026-07-02)
|
||||||
|
|
||||||
|
❌ "查证属实,无遗漏" → 实际没跑任何工具
|
||||||
|
❌ "找不到第2份" → 实际数据在tracker里,自己之前还列过表
|
||||||
|
❌ "你记得叫什么名字吗?" → 把验证责任转嫁用户
|
||||||
|
|
||||||
|
✅ 正确做法:跑完全部5个数据源 → 列出原始数据 → 标注不确定项 → 再给结论
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Audit 待审查 and 任务交付 directories against tracker.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 ~/.hermes/skills/legal/contract-pass-workflow/references/cleanup-audit.py
|
||||||
|
|
||||||
|
Prints a matrix showing which files are:
|
||||||
|
- In tracker (and their status/age/cleaned flag)
|
||||||
|
- NOT in tracker (orphans that will never be auto-cleaned)
|
||||||
|
- Should be cleaned (>24h + completed + cleaned=false)
|
||||||
|
|
||||||
|
Does NOT delete anything. Pure diagnostic.
|
||||||
|
"""
|
||||||
|
import json, os, subprocess
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
BJT = timezone(timedelta(hours=8))
|
||||||
|
now = datetime.now(BJT)
|
||||||
|
|
||||||
|
TRACKER = os.path.expanduser('~/.hermes/data/contract-tracker.json')
|
||||||
|
BASE = os.path.expanduser('~/nextcloud/data/data/doro/files/Doro合同审查任务')
|
||||||
|
待审查 = os.path.join(BASE, '待审查')
|
||||||
|
任务交付 = os.path.join(BASE, '任务交付')
|
||||||
|
|
||||||
|
def nc_ls(path):
|
||||||
|
"""List files in a Nextcloud-managed directory (needs sudo)."""
|
||||||
|
r = subprocess.run(['sudo', 'ls', path], capture_output=True, text=True)
|
||||||
|
return [f for f in r.stdout.strip().split('\n') if f] if r.stdout.strip() else []
|
||||||
|
|
||||||
|
def file_age_hours(path):
|
||||||
|
"""Get file age in hours from mtime."""
|
||||||
|
r = subprocess.run(['sudo', 'stat', '-c', '%Y', path], capture_output=True, text=True)
|
||||||
|
if r.stdout.strip():
|
||||||
|
mtime = int(r.stdout.strip())
|
||||||
|
return (now - datetime.fromtimestamp(mtime, tz=BJT)).total_seconds() / 3600
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with open(TRACKER) as f:
|
||||||
|
tracker = json.load(f)
|
||||||
|
|
||||||
|
# Build lookup: filename -> list of tracker records
|
||||||
|
lookup = {}
|
||||||
|
for c in tracker['contracts']:
|
||||||
|
for key in ['delivered_filename', 'original_filename', 'converted_filename']:
|
||||||
|
fn = c.get(key, '')
|
||||||
|
if fn:
|
||||||
|
lookup.setdefault(fn, []).append(c)
|
||||||
|
|
||||||
|
orphans = []
|
||||||
|
|
||||||
|
for label, directory in [('待审查', 待审查), ('任务交付', 任务交付)]:
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f" {label} ({directory})")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
files = nc_ls(directory)
|
||||||
|
if not files:
|
||||||
|
print(" (empty)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for fn in sorted(files):
|
||||||
|
records = lookup.get(fn, [])
|
||||||
|
age = file_age_hours(os.path.join(directory, fn))
|
||||||
|
is_companion = any(k in fn for k in ['审查意见', '合同流程单'])
|
||||||
|
|
||||||
|
if records:
|
||||||
|
for r in records:
|
||||||
|
ts = r.get('xlsx_updated_at', '')
|
||||||
|
h = (now - datetime.fromisoformat(ts)).total_seconds() / 3600 if ts else -1
|
||||||
|
should = r['status'] == 'completed' and h > 24
|
||||||
|
flag = '🔴 SHOULD_CLEAN' if should else '⏳ waiting'
|
||||||
|
print(f" {fn}")
|
||||||
|
print(f" seq={r['seq']} | {h:.0f}h | cleaned={r.get('cleaned')} | {flag}")
|
||||||
|
else:
|
||||||
|
tag = '📋 COMPANION_ORPHAN' if is_companion else '⚠️ NOT_IN_TRACKER'
|
||||||
|
print(f" {fn}")
|
||||||
|
print(f" {tag} | age={age:.0f}h")
|
||||||
|
orphans.append((label, fn, age))
|
||||||
|
|
||||||
|
if orphans:
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f" ORPHANS SUMMARY: {len(orphans)} files not tracked")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
for label, fn, age in orphans:
|
||||||
|
print(f" [{label}] {fn} ({age:.0f}h old)")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user