feat: stabilize paperforge query planning

This commit is contained in:
Research Assistant 2026-05-27 21:20:15 +08:00
parent f68f45f3a2
commit 03ad9ce64f
26 changed files with 1851 additions and 57 deletions

View file

@ -0,0 +1,898 @@
# PaperForge Query Planning and Skill Trigger Design
**Date:** 2026-05-27
**Status:** Proposed
**Audience:** Maintainers, CLI contributors, skill authors, agent implementers
---
## 1. Summary
PaperForge's current literature retrieval flow is too dependent on agent-side judgment about how to write queries for `search`, when to prefer `retrieve`, and when a zero-result search means "not found" versus "query was malformed for this command".
This design introduces a new CLI planning layer:
- `paperforge query-plan`
It becomes the truth source for:
1. query classification
2. retrieval mode selection
3. query writing guidance
4. zero-result fallback policy
5. user-interactive fallback suggestions when content lookup cannot safely degrade automatically
This design also proposes a minimal skill-layer improvement:
- keep the current top-level compound routing order intact
- improve the skill trigger contract so agents are more reliably forced into the `paperforge` skill for literature retrieval, paper reading, and evidence lookup tasks
- make molecule-level retrieval execution depend on `query-plan` rather than freeform agent improvisation
The goal is to make paper lookup and content lookup reliable without destabilizing the existing PaperForge skill graph.
---
## 2. Problems Observed
### 2.1 `search` is easy for agents to misuse
Agents currently write natural-language or mixed-structure queries such as:
- `Lin 2024 Electrical Stimulation`
This is unreliable for `paperforge search` because `search` behaves like metadata retrieval, not semantic fulltext discovery.
In practice, the query should be decomposed before execution:
- first-pass: `author + year`
- second-pass narrowing: human-visible candidate inspection, optionally using title words
### 2.2 Zero results are semantically ambiguous
A zero-result `search` currently does not distinguish between:
1. the paper is probably not in the library
2. the query was poorly formed for metadata search
3. the user is actually asking for content lookup rather than paper lookup
Agents then incorrectly conclude:
- `库里没有`
This is a contract failure.
### 2.3 `retrieve` is not just a low-confidence fallback
For content lookup tasks such as:
- finding where a method, parameter, term, or claim appears inside papers
`retrieve` should be the primary path, not the last step in a generic paper-discovery ladder.
### 2.4 Metadata search and fulltext search have different query-writing rules
The current system does not expose this clearly enough to agents.
As a result:
1. agents write retrieval-style queries for metadata search
2. agents write metadata-style queries for content retrieval
3. agents use one tool's output count to reason about another tool's scope
This causes both false negatives and unstable fallback behavior.
### 2.5 Skill triggering is still too phrase-fragile
The current `paperforge` skill frontmatter `description` contains many examples, but it still behaves like a keyword bucket rather than an explicit intent contract.
For high-value tasks such as:
1. paper discovery
2. known-paper reading
3. evidence lookup
4. deep reading
5. literature collection inspection
the skill should be treated as mandatory, not optional.
---
## 3. Design Goals
### 3.1 Primary Goals
1. Move query decomposition and retrieval planning into the CLI.
2. Prevent agents from interpreting zero-result search as library absence without additional reasoning.
3. Make `retrieve` the default for content lookup intent.
4. Make query-writing rules explicit per retrieval strategy.
5. Improve skill-trigger reliability without replacing the current skill graph.
### 3.2 Secondary Goals
1. Reduce prompt-only retrieval rules that can drift from implementation.
2. Preserve the existing compound routing order in `SKILL.md`.
3. Make testing easier with deterministic planning output.
### 3.3 Non-Goals
1. No redesign of the five top-level research intents.
2. No replacement of `/pf-paper` or `/pf-deep`.
3. No silent auto-reading of source files outside current safety rules.
4. No attempt to make `search` itself become semantic fulltext retrieval.
---
## 4. Design Principle
PaperForge retrieval should separate two user jobs:
### 4.1 Paper Lookup
The user wants to identify which paper(s) to inspect.
Examples:
- `找 Lin 2024 那篇`
- `找几篇关于 electrical stimulation 的文章`
- `这个 collection 里有什么`
### 4.2 Content Lookup
The user wants to find where some concept, parameter, method, or claim appears in paper content.
Examples:
- `找 galvanotaxis`
- `找 75 Hz`
- `找这句话的支持`
- `看看哪些文章正文里提到了某个方法`
These two jobs must not share the same first-pass query contract.
---
## 5. New CLI Command: `paperforge query-plan`
### 5.1 Purpose
`query-plan` is a planning command, not a retrieval command.
It does not search the library directly. It tells the agent:
1. what kind of query this is
2. which command should be used first
3. how the query should be rewritten
4. what fallback sequence should apply
### 5.2 Example CLI
```bash
paperforge query-plan "<query>" --intent discover --json
paperforge query-plan "<query>" --intent content --json
paperforge query-plan "<query>" --intent known-paper --json
```
### 5.3 Intent Values
Allowed planning intents:
- `discover`
- `content`
- `known-paper`
These map onto existing molecules without replacing them:
- `discover` -> `discover-papers`
- `content` -> `find-supporting-evidence`
- `known-paper` -> `read-known-paper`
### 5.4 Output Contract
Recommended shape:
```json
{
"ok": true,
"command": "query-plan",
"version": "1.5.x",
"data": {
"intent": "discover",
"query_class": "mixed_query",
"signals": {
"doi": null,
"zotero_key": null,
"citation_key": null,
"author_tokens": ["Lin"],
"year_tokens": [2024],
"title_like_tokens": ["Electrical", "Stimulation"],
"content_terms": []
},
"signal_priority": [
"doi",
"zotero_key",
"citation_key",
"author",
"year",
"title",
"topic"
],
"recommended_primary": {
"command": "search",
"args": {
"query": "Lin",
"year_from": 2024,
"year_to": 2024,
"limit": 10
}
},
"query_writing_rules": [
"For metadata search, prefer author and year over mixed natural-language query strings.",
"Do not include title words in first-pass author+year lookup."
],
"fallback_plan": [
{
"when": "zero_results",
"action": "report_noncanonical_query_risk"
},
{
"when": "multiple_results",
"action": "let_user_or_agent_visually_narrow"
}
]
}
}
```
---
## 6. Query Classification
`query-plan` should classify input into stable, agent-facing categories.
### 6.1 `identifier_exact`
Used when the input contains:
- DOI
- zotero key
- citation key
Primary route:
- `paper-context`
No freeform search should happen first.
### 6.2 `author_year`
Used when the input contains a plausible author token and a year.
Primary route:
- `search "<author>" --year-from YYYY --year-to YYYY`
Title words must not be included in first-pass execution.
### 6.3 `metadata_topic`
Used when the user is discovering papers by topic, title-like words, domain, or collection.
Primary route:
- `search`
- or `context --collection` / `context --domain` when scope terms are explicit
### 6.4 `content_term`
Used when the user wants to find content inside papers:
- parameter
- method
- exact term
- evidence support
Primary route:
- `retrieve`
### 6.5 `mixed_query`
Used when the query mixes multiple signal types into one string.
Example:
- `Lin 2024 Electrical Stimulation`
For `discover`, mixed queries should be normalized into structured metadata lookup.
For `content`, mixed queries should be split into content-bearing terms only if the user is searching inside papers.
---
## 7. Signal Reliability Hierarchy
The CLI should expose a stable signal hierarchy so the agent stops guessing.
### 7.1 High-Reliability Signals
These should dominate routing:
1. `doi`
2. `zotero_key`
3. `citation_key`
4. `author`
5. `year`
### 7.2 Medium-Reliability Signals
1. short title phrase
2. explicit domain
3. explicit collection
### 7.3 Lower-Reliability Signals
1. broad topic phrase
2. natural-language description
3. long mixed query strings
### 7.4 Rule
When a high-reliability signal exists, lower-reliability tokens must not corrupt the first-pass query.
Example:
- if author + year are present, title words do not belong in first-pass `search`
---
## 8. Query Writing Rules by Command
These rules should appear both in `query-plan` output and in skill prose.
### 8.1 `paper-context`
Use when:
- DOI
- zotero key
- citation key
- already-locked single paper
Writing rule:
- use exact identifier, no query rewriting
### 8.2 `search`
Use when:
- paper lookup
- paper discovery
- collection/domain inspection
Writing rules:
1. prefer author and year for first-pass known-paper lookup
2. prefer short metadata-facing terms
3. do not mix long title fragments into first-pass author+year lookup
4. treat titles as a manual narrowing aid after candidate generation, not as mandatory first-pass input
### 8.3 `retrieve`
Use when:
- content lookup
- evidence lookup
- in-paper term finding
- method/parameter/claim search
Writing rules:
1. prefer content-bearing terminology
2. use terms as they are likely to appear in正文
3. completeness matters more than metadata neatness
4. do not treat `retrieve` as final evidence without later verification when exact quoting matters
### 8.4 `rg` / `grep`
Use when:
- exact fulltext verification is needed
- `retrieve` is unavailable or insufficient
Writing rules:
1. prefer literal strings, units, abbreviations, parameter patterns, and stable terms
2. do not use long conversational questions as grep queries
---
## 9. Zero-Result Semantics
### 9.1 Problem
A zero-result `search` should not automatically mean:
- `库里没有`
### 9.2 New Contract
When `query-plan` classifies the query as noncanonical for the chosen command, the CLI should distinguish:
1. `no_match_after_normalization`
2. `noncanonical_query_for_command`
3. `likely_wrong_intent_for_command`
### 9.3 Recommended Behavior
For known-paper and discover flows:
1. `query-plan` rewrites the first pass
2. the molecule executes the recommended primary command
3. if zero results remain after normalized first pass, the response may say the library likely lacks a match
4. before that point, the response must not treat raw zero results as absence
### 9.4 Why Planning Is Better Than Silent Auto-Retry in `search`
This design intentionally keeps the retry logic visible at the planning layer.
That preserves:
1. transparency
2. deterministic testing
3. compatibility with existing molecules
---
## 10. Content Lookup Contract
### 10.1 Primary Rule
If the user's intent is to find something inside paper content, the first retrieval command should be:
- `retrieve`
This is true even if paper discovery would also be possible.
### 10.2 `retrieve` Is Not a Mere Late Fallback
For content lookup, `retrieve` is the primary route, not a low-priority optional add-on.
### 10.3 Fallback When `retrieve` Is Unavailable or Returns 0
Do not automatically conclude failure.
Instead, return a structured fallback mode suggestion. The preferred fallback family is:
- fulltext exact search (`rg` / `grep`)
### 10.4 User-Interactive Fallback
When `retrieve` is unavailable or insufficient, the CLI should support returning:
- `interactive_fallback_required: true`
- `suggested_modes`
Example modes:
1. limit to a collection/domain and run `rg`
2. run broader fulltext grep now
3. use metadata search first to narrow paper candidates
This preserves user control when exact fulltext search could be expensive or noisy.
---
## 11. Scope Assessment Rules
### 11.1 Important Constraint
Metadata search result count must not be treated as the truth source for fulltext grep scope.
This is because:
1. metadata and正文 have different recall behavior
2. a term absent from abstract/title may still be common in methods or results
3. a term common in metadata may still be rare in正文
### 11.2 Recommended Scope Sources
Scope estimation should prefer:
1. explicit collection/domain restriction
2. inventory-level counts from `context`
3. fulltext-ready counts from runtime/index data
4. only then metadata search counts, and only as weak hints
### 11.3 CLI Output Suggestion
When relevant, `query-plan` may return:
```json
"scope_assessment": {
"source": "collection_inventory",
"estimated_paper_count": 42,
"fulltext_ready_count": 39,
"confidence": "medium",
"recommended_mode": "ask_user_before_broad_grep"
}
```
This lets the skill ask the user with evidence rather than guesswork.
---
## 12. Skill Routing Changes: Minimal and Safe
### 12.1 Keep the Current Compound Order
The current top-level routing order in `SKILL.md` should remain intact:
1. mechanical commands
2. aliases
3. capture
4. known paper
5. discover papers
6. supporting evidence
7. clarify
8. post-action capture
This design does not change the compound graph.
### 12.2 Insert `query-plan` Only Inside Molecules
Recommended insertion points:
- `read-known-paper.md`
- `discover-papers.md`
- `find-supporting-evidence.md`
The compound should not call `query-plan` globally.
Instead:
1. compound decides the molecule
2. molecule calls `query-plan`
3. molecule executes the recommended retrieval command
This keeps routing simple and avoids top-level drift.
### 12.3 Molecule-Specific Use
#### `read-known-paper`
Use `query-plan --intent known-paper`
Expected outcomes:
- identifier -> `paper-context`
- author+year -> normalized `search`
- vague title/topic -> `search` or `paper-status`
#### `discover-papers`
Use `query-plan --intent discover`
Expected outcomes:
- collection/domain inventory route
- metadata search route
- normalized mixed-query route
#### `find-supporting-evidence`
Use `query-plan --intent content`
Expected outcomes:
- primary `retrieve`
- interactive fallback suggestion when vector retrieval is unavailable or returns poor signal
---
## 13. Skill Trigger Improvements
### 13.1 Problem
The current `description` lists many example phrases, but it does not strongly encode the idea that certain intents must always load the PaperForge skill.
### 13.2 Proposed Direction
Revise the skill frontmatter description and top prose so the trigger is intent-driven, not just phrase-driven.
### 13.3 Target Trigger Semantics
The `paperforge` skill must be invoked whenever the user is asking for any of the following:
1. literature retrieval from the vault/library
2. identifying or reading a specific paper in the library
3. finding evidence, parameters, methods, or terms inside stored papers
4. inspecting collections or domains in the literature vault
5. deep reading or structured discussion of a stored paper
6. saving paper-derived research knowledge into PaperForge logs
### 13.4 Description Direction
The description should speak in terms of user intents, for example:
- mandatory for paper lookup, paper discovery, evidence lookup, deep reading, and PaperForge research-memory capture
This is preferable to relying only on examples like:
- `找文献`
- `找支持`
- `collection`
### 13.5 Why This Helps
Intent-driven trigger wording:
1. generalizes better to unseen phrasing
2. reduces accidental non-use of the skill
3. aligns better with the AGENTS.md rule that literature retrieval and reading must go through PaperForge
---
## 14. CLI and Documentation Consistency Fixes
The current system must also fix misleading terminology.
### 14.1 `search` Description Fix
`search` should no longer be described as:
- full-text search across the library
It should be described as:
- metadata/FTS search across indexed paper fields such as title, abstract, author, journal, domain, and collection
### 14.2 `retrieve` Description Fix
`retrieve` should be described as:
- semantic content retrieval across OCR fulltext
### 14.3 Agent Context Rules
`agent-context` should explicitly state:
1. `search` and `retrieve` have different query-writing contracts
2. content lookup should begin with `retrieve`
3. author+year first-pass lookup should exclude title words
4. raw zero-result search is not enough to conclude absence
---
## 15. Versioning and Compatibility Model
### 15.1 Problem
The current skill preflight expects the skill frontmatter version to match the plugin/package version.
This is too coarse and creates ambiguity:
1. plugin code may change while the skill contract does not
2. the skill contract may change while plugin packaging changes are unrelated
3. maintainers cannot tell whether a mismatch means a real incompatibility or only a release-version drift
### 15.2 Design Rule
PaperForge should distinguish:
1. release/package version
2. skill contract version
3. compatibility level
These are not the same thing and should not be forced into one shared version string.
### 15.3 Proposed Fields
Recommended model:
- `plugin_version`
- `skill_version`
- `skill_api_version`
#### `plugin_version`
Meaning:
- the released PaperForge package/plugin version
Used for:
- manifest
- installer/update flow
- git tag / release tracking
- package-level changelog
Example:
- `1.5.14`
#### `skill_version`
Meaning:
- the concrete shipped version of the `paperforge` skill bundle
Used for:
- skill deployment identity
- debugging whether the vault has the latest skill copy
- distinguishing skill-only updates from package-only updates
Example:
- `2026-05-27.1`
#### `skill_api_version`
Meaning:
- the compatibility contract version between CLI planning/runtime outputs and the skill's expectations
Used for:
- preflight compatibility checks
- determining whether the currently deployed skill can safely interpret the current CLI outputs
Example:
- `2`
### 15.4 Compatibility Principle
Agents should care primarily about:
- `skill_api_version`
Maintainers may also inspect:
- `skill_version`
Package release tooling should care primarily about:
- `plugin_version`
### 15.5 Preflight Rule Change
The current rule:
- skill frontmatter version must equal package version
should be removed.
Replace it with:
1. confirm the skill exists and is readable
2. confirm the deployed skill reports `skill_api_version` compatible with the CLI
3. report `plugin_version` and `skill_version` for debugging
4. if `skill_api_version` mismatches, tell the user to run `paperforge update`
### 15.6 Bootstrap Contract Change
`pf_bootstrap.py` should return all three fields when available:
```json
{
"plugin_version": "1.5.14",
"skill_version": "2026-05-27.1",
"skill_api_version": 2
}
```
This should replace the current weaker pattern where the skill version may be inferred only from the skill file's frontmatter.
### 15.7 Example Scenarios
#### Scenario A: package update, skill unchanged
- `plugin_version` changes
- `skill_version` unchanged
- `skill_api_version` unchanged
Interpretation:
- safe; the skill contract did not change
#### Scenario B: skill routing update, package release also happens
- `plugin_version` changes
- `skill_version` changes
- `skill_api_version` may or may not change
Interpretation:
- if `skill_api_version` unchanged, this is a compatible skill refinement
- if `skill_api_version` changes, the deployed skill and CLI must be updated together
#### Scenario C: local vault still has old deployed skill
- installed CLI/package is newer
- deployed skill in vault is older
- `skill_api_version` mismatch
Interpretation:
- this is a real compatibility problem and the user should update/redeploy the skill
### 15.8 Frontmatter Direction
The `paperforge` skill frontmatter should no longer overload a single `version` field to mean package release compatibility.
Recommended direction:
- keep `source: paperforge`
- add `skill_version`
- add `skill_api_version`
If a plain `version` field is still required by tooling, it should be documented as the skill bundle version, not the package version.
---
## 16. Testing Focus
### 15.1 CLI Query Planning Tests
Add tests for:
1. DOI -> `paper-context`
2. zotero key -> `paper-context`
3. `Lin 2024 Electrical Stimulation` with `discover` intent -> normalized `search(author + year)`
4. `galvanotaxis` with `content` intent -> `retrieve`
5. content lookup without vector availability -> interactive fallback plan
### 15.2 Skill Contract Tests
Add tests to assert:
1. molecules reference `query-plan`
2. content molecule makes `retrieve` the primary route
3. known-paper molecule prioritizes identifiers and author+year
4. skill description contains intent-level trigger language
### 15.3 Regression Target
The key regression to prevent is:
- raw mixed query -> zero results -> agent claims the paper is absent
---
## 17. Implementation Sequence
Recommended order:
1. add `paperforge query-plan` CLI command
2. add query classification and planning logic
3. update `agent-context` and command help text
4. introduce `plugin_version` / `skill_version` / `skill_api_version` contract in bootstrap and skill metadata
5. revise skill `description` and top trigger prose
6. update `read-known-paper.md`
7. update `discover-papers.md`
8. update `find-supporting-evidence.md`
9. add tests for CLI planning, version compatibility, and skill contracts
---
## 18. Final Principle
The agent should not be responsible for inventing PaperForge retrieval syntax.
The agent's job should be:
1. route to the correct molecule
2. ask the CLI how to search
3. execute the recommended command
4. interpret the result honestly
The CLI's job should be:
1. classify the query
2. expose signal strength
3. recommend the correct retrieval strategy
4. prevent false "not found" conclusions from malformed first-pass queries
This preserves the current skill graph while making retrieval behavior far more stable.

View file

@ -272,12 +272,17 @@ def build_parser() -> argparse.ArgumentParser:
p_embed_stop = p_embed_sp.add_parser("stop", help="Stop running embed build")
p_embed_stop.add_argument("--json", action="store_true")
p_retrieve = sub.add_parser("retrieve", help="Semantic search across OCR fulltext")
p_retrieve = sub.add_parser("retrieve", help="Semantic content retrieval across OCR fulltext")
p_retrieve.add_argument("query", help="Search query")
p_retrieve.add_argument("--json", action="store_true")
p_retrieve.add_argument("--limit", type=int, default=5)
p_retrieve.add_argument("--expand", action="store_true", default=True)
p_qp = sub.add_parser("query-plan", help="Classify a literature query and recommend the first retrieval command")
p_qp.add_argument("query", help="User query to classify")
p_qp.add_argument("--intent", choices=["discover", "content", "known-paper"], required=True, help="Retrieval intent")
p_qp.add_argument("--json", action="store_true", help="Output as JSON")
# prune
p_prune = sub.add_parser("prune", help="Delete orphan paper artifacts (dry-run by default)")
p_prune.add_argument("--force", action="store_true", help="Actually delete (default: dry-run)")
@ -296,8 +301,8 @@ def build_parser() -> argparse.ArgumentParser:
p_paper_status.add_argument("query", help="Paper identifier (zotero_key, DOI, title, alias)")
p_paper_status.add_argument("--json", action="store_true", help="Output as JSON")
p_pc = sub.add_parser("paper-context", help="Get full context for a paper (metadata + reading notes + corrections)")
p_pc.add_argument("key", help="Zotero key")
p_pc = sub.add_parser("paper-context", help="Get full context for a paper by zotero key, DOI, or citation key")
p_pc.add_argument("key", help="Paper identifier (zotero key, DOI, or citation key)")
p_pc.add_argument("--json", action="store_true", help="Output as JSON")
p_rl = sub.add_parser("reading-log", help="Record or export reading notes")
@ -330,8 +335,8 @@ def build_parser() -> argparse.ArgumentParser:
p_pl.add_argument("--limit", type=int, default=50, help="Max entries to list")
p_pl.add_argument("--json", action="store_true", help="Output as PFResult JSON")
p_search = sub.add_parser("search", help="Full-text search across the library")
p_search.add_argument("query", help="Search query (supports FTS5 syntax)")
p_search = sub.add_parser("search", help="Metadata FTS search across indexed paper fields")
p_search.add_argument("query", help="Search query for title/abstract/author/journal/domain/collection metadata")
p_search.add_argument("--json", action="store_true", help="Output as JSON")
p_search.add_argument("--limit", type=int, default=20, help="Max results")
p_search.add_argument("--domain", help="Filter by domain")
@ -462,9 +467,6 @@ def main(argv: list[str] | None = None) -> int:
if argv is None:
argv = sys.argv[1:]
_resolve_pipeline()
_import_worker_functions()
parser = build_parser()
args = parser.parse_args(argv)
@ -472,6 +474,20 @@ def main(argv: list[str] | None = None) -> int:
parser.print_help()
return 1
# Resolve/import worker modules only for commands that actually need them.
lightweight_commands = {
"paths", "sync", "ocr", "status", "deep-reading", "deep-finalize",
"context", "repair", "dashboard", "memory", "embed", "retrieve",
"query-plan", "prune", "paper-status", "paper-context", "reading-log",
"project-log", "search", "agent-context", "runtime-health", "doctor",
"update", "setup", "selection-sync", "index-refresh", "base-refresh",
}
if args.command in lightweight_commands:
_resolve_pipeline()
worker_import_commands = {"status", "deep-reading", "ocr", "selection-sync", "index-refresh", "base-refresh"}
if args.command in worker_import_commands:
_import_worker_functions()
# Resolve vault
try:
vault = resolve_vault(cli_vault=args.vault)
@ -577,6 +593,11 @@ def main(argv: list[str] | None = None) -> int:
return run(args)
if args.command == "query-plan":
from paperforge.commands.query_plan import run
return run(args)
if args.command == "prune":
from paperforge.commands.prune import run
@ -696,6 +717,7 @@ def _cmd_paths(vault: Path, args: argparse.Namespace) -> int:
filtered["vault"] = str(vault.resolve())
filtered["worker_script"] = str(paths["worker_script"].resolve())
filtered["pf_deep_script"] = str(paths["pf_deep_script"].resolve())
filtered["ld_deep_script"] = filtered["pf_deep_script"]
print(json.dumps(filtered, ensure_ascii=False, indent=2))
else:
for key, path_str in sorted(all_paths.items()):

View file

@ -13,6 +13,7 @@ _COMMAND_REGISTRY: dict[str, str] = {
"memory": "paperforge.commands.memory",
"embed": "paperforge.commands.embed",
"retrieve": "paperforge.commands.retrieve",
"query-plan": "paperforge.commands.query_plan",
"paper-status": "paperforge.commands.paper_status",
"agent-context": "paperforge.commands.agent_context",
"prune": "paperforge.commands.prune",

View file

@ -9,17 +9,21 @@ from paperforge.core.result import PFError, PFResult
from paperforge.memory.context import get_agent_context
COMMANDS = {
"query-plan": {
"usage": "paperforge query-plan <query> --intent discover|content|known-paper --json",
"purpose": "Classify the query, choose the best first retrieval command, and explain fallback rules",
},
"paper-status": {
"usage": "paperforge paper-status <zotero_key|citation_key|doi|title> --json",
"purpose": "Look up one paper's full status and recommended next action",
},
"search": {
"usage": "paperforge search <query> --json [--domain NAME] [--ocr done|pending|failed|processing] [--year-from N] [--year-to N] [--limit N] [--lifecycle indexed|pdf_ready|fulltext_ready|deep_read_done]",
"purpose": "Full-text search with optional domain and workflow-state filters",
"purpose": "Metadata FTS search over title, abstract, authors, journal, domain, and collection fields",
},
"retrieve": {
"usage": "paperforge retrieve <query> --json [--limit N]",
"purpose": "Semantic search across OCR fulltext — discover papers by body content, not just title/abstract",
"purpose": "Semantic content retrieval across OCR fulltext — use this first when the user wants something inside papers",
},
"context": {
"usage": "paperforge context <key> | --domain D | --collection P | --all",
@ -45,7 +49,11 @@ RULES = [
"Read source files only after resolving candidates via paper-status, search, retrieve, or context.",
"To locate a paper: start with collection scope if known, then expand to full library search.",
"Paper discovery must use multi-arm strategy: retrieve (body text) + search (metadata) + context --collection (inventory). Never rely on a single search tool.",
"Use query-plan before freeform retrieval when the query mixes author/year/title/content signals.",
"For known-paper lookup, DOI/zotero_key/citation_key are higher-signal than title words; author+year first-pass search should exclude title terms.",
"For content lookup, start with retrieve. Search is not semantic fulltext discovery.",
"When a search returns > 20 results, present the count to the user and offer to narrow — never silently skip large result sets.",
"A raw zero-result search does not prove library absence when the query is noncanonical for metadata search.",
"Check embed status --json (db_exists + chunk_count) before calling retrieve; skip retrieve if vector index is unavailable.",
]

View file

@ -9,6 +9,7 @@ from paperforge.core.errors import ErrorCode
from paperforge.core.result import PFError, PFResult
from paperforge.memory.db import get_connection, get_memory_db_path
from paperforge.memory.permanent import get_corrections_for_paper, get_reading_notes_for_paper
from paperforge.memory.query import lookup_paper
def _build_paper_context(vault, key: str) -> dict | None:
@ -20,13 +21,17 @@ def _build_paper_context(vault, key: str) -> dict | None:
conn = get_connection(db_path, read_only=True)
try:
matches = lookup_paper(conn, key)
if not matches or len(matches) != 1:
return None
resolved_key = matches[0]["zotero_key"]
row = conn.execute(
"""SELECT zotero_key, citation_key, title, year, doi, journal,
first_author, domain, collection_path, has_pdf,
ocr_status, analyze, deep_reading_status, lifecycle,
next_step, pdf_path, note_path, fulltext_path, paper_root
FROM papers WHERE zotero_key = ?""",
(key,),
(resolved_key,),
).fetchone()
if not row:
@ -34,7 +39,7 @@ def _build_paper_context(vault, key: str) -> dict | None:
paper = dict(row)
prior_notes = get_reading_notes_for_paper(vault, key)
prior_notes = get_reading_notes_for_paper(vault, resolved_key)
corrections = []
corr_rows = conn.execute(
@ -42,7 +47,7 @@ def _build_paper_context(vault, key: str) -> dict | None:
FROM paper_events
WHERE paper_id = ? AND event_type = 'correction_note'
ORDER BY created_at DESC""",
(key,),
(resolved_key,),
).fetchall()
seen_ids: set[str] = set()
for cr in corr_rows:
@ -57,7 +62,7 @@ def _build_paper_context(vault, key: str) -> dict | None:
if orig_id:
seen_ids.add(orig_id)
jsonl_corrections = get_corrections_for_paper(vault, key)
jsonl_corrections = get_corrections_for_paper(vault, resolved_key)
for c in jsonl_corrections:
cid = c.get("original_id", "")
if cid and cid in seen_ids:

View file

@ -0,0 +1,37 @@
from __future__ import annotations
import argparse
import sys
from paperforge import __version__ as PF_VERSION
from paperforge.core.errors import ErrorCode
from paperforge.core.result import PFError, PFResult
from paperforge.query_planning import build_query_plan, enrich_query_plan_with_runtime
def run(args: argparse.Namespace) -> int:
try:
data = build_query_plan(args.query, args.intent)
data = enrich_query_plan_with_runtime(data, args.vault_path)
result = PFResult(ok=True, command="query-plan", version=PF_VERSION, data=data)
except Exception as exc:
result = PFResult(
ok=False,
command="query-plan",
version=PF_VERSION,
error=PFError(code=ErrorCode.INTERNAL_ERROR, message=str(exc)),
)
if args.json:
print(result.to_json())
else:
if result.ok:
primary = result.data["recommended_primary"]
print(f"Intent: {result.data['intent']}")
print(f"Query class: {result.data['query_class']}")
print(f"Primary: {primary['command']}")
print(f"Args: {primary['args']}")
else:
print(f"Error: {result.error.message}", file=sys.stderr)
return 0 if result.ok else 1

View file

@ -8,6 +8,26 @@ from paperforge.core.errors import ErrorCode
from paperforge.core.result import PFError, PFResult
from paperforge.embedding import retrieve_chunks
from paperforge.memory.db import get_connection, get_memory_db_path
from paperforge.query_planning import build_query_plan, enrich_query_plan_with_runtime
def _looks_generic_chunk(text: str) -> bool:
compact = (text or "").strip().lower()
if not compact:
return True
if compact in {"[figure]", "none.", "or", "### keywords", "### abbreviations", "### reference", "### conclusion", "### references"}:
return True
if len(compact) <= 12:
return True
return False
def _is_low_confidence_semantic_result(chunks: list[dict]) -> bool:
if not chunks:
return False
generic_top = sum(1 for chunk in chunks[:5] if _looks_generic_chunk(chunk.get("chunk_text", "")))
max_score = max(float(chunk.get("score", 0) or 0) for chunk in chunks[:5])
return generic_top >= 3 or max_score < 0.62
def run(args: argparse.Namespace) -> int:
@ -19,6 +39,7 @@ def run(args: argparse.Namespace) -> int:
from paperforge.embedding import get_embed_status
status = get_embed_status(vault)
if not status.get("healthy", True):
plan = enrich_query_plan_with_runtime(build_query_plan(query, "content"), vault)
result = PFResult(
ok=False,
command="retrieve",
@ -30,6 +51,9 @@ def run(args: argparse.Namespace) -> int:
data={
"next_action": "paperforge embed build --force",
"details": status.get("error", ""),
"interactive_fallback_required": plan.get("interactive_fallback_required", False),
"scope_assessment": plan.get("scope_assessment"),
"suggested_modes": plan.get("suggested_modes", []),
},
)
if args.json:
@ -39,6 +63,7 @@ def run(args: argparse.Namespace) -> int:
return 1
if status.get("chunk_count", 0) == 0:
plan = enrich_query_plan_with_runtime(build_query_plan(query, "content"), vault)
result = PFResult(
ok=False,
command="retrieve",
@ -47,7 +72,12 @@ def run(args: argparse.Namespace) -> int:
code=ErrorCode.PATH_NOT_FOUND,
message="Vector index is empty. Run paperforge embed build first.",
),
data={"next_action": "paperforge embed build"},
data={
"next_action": "paperforge embed build",
"interactive_fallback_required": plan.get("interactive_fallback_required", False),
"scope_assessment": plan.get("scope_assessment"),
"suggested_modes": plan.get("suggested_modes", []),
},
)
if args.json:
print(result.to_json())
@ -83,7 +113,38 @@ def run(args: argparse.Namespace) -> int:
conn.close()
data = {"query": query, "chunks": chunks, "count": len(chunks)}
result = PFResult(ok=True, command="retrieve", version=PF_VERSION, data=data)
warnings: list[str] = []
next_actions: list[dict] = []
if len(chunks) == 0:
plan = enrich_query_plan_with_runtime(build_query_plan(query, "content"), vault)
data["query_diagnostic"] = {
"scope_assessment": plan.get("scope_assessment"),
"interactive_fallback_required": plan.get("interactive_fallback_required", False),
"suggested_modes": plan.get("suggested_modes", []),
}
warnings.append("Semantic retrieval returned no chunks. This does not prove the content is absent from the library.")
next_actions.append(
{
"command": "paperforge query-plan",
"reason": "Review the fallback modes for content lookup and fulltext verification.",
}
)
elif _is_low_confidence_semantic_result(chunks):
plan = enrich_query_plan_with_runtime(build_query_plan(query, "content"), vault)
data["query_diagnostic"] = {
"scope_assessment": plan.get("scope_assessment"),
"interactive_fallback_required": True,
"suggested_modes": plan.get("suggested_modes", []),
"reason": "Top semantic hits look generic or weakly related to the query.",
}
warnings.append("Semantic retrieval returned low-confidence hits. Verify with fulltext grep or narrow the scope before treating these as evidence.")
next_actions.append(
{
"command": "paperforge query-plan",
"reason": "Inspect fallback modes for exact fulltext verification.",
}
)
result = PFResult(ok=True, command="retrieve", version=PF_VERSION, data=data, warnings=warnings, next_actions=next_actions)
if args.json:
print(result.to_json())

View file

@ -8,6 +8,7 @@ from paperforge.core.errors import ErrorCode
from paperforge.core.result import PFError, PFResult
from paperforge.memory.db import get_connection, get_memory_db_path
from paperforge.memory.fts import search_papers
from paperforge.query_planning import build_query_plan, enrich_query_plan_with_runtime
def run(args: argparse.Namespace) -> int:
@ -58,7 +59,32 @@ def run(args: argparse.Namespace) -> int:
"next_step": args.next_step,
},
}
result = PFResult(ok=True, command="search", version=PF_VERSION, data=data)
warnings: list[str] = []
next_actions: list[dict] = []
if len(results) == 0:
plan = enrich_query_plan_with_runtime(build_query_plan(query, "discover"), vault)
data["query_diagnostic"] = {
"query_class": plan["query_class"],
"recommended_primary": plan["recommended_primary"],
"query_writing_rules": plan["query_writing_rules"],
"scope_assessment": plan.get("scope_assessment"),
}
if plan["query_class"] in {"mixed_query", "author_year"}:
warnings.append("Zero results may reflect a noncanonical metadata query rather than library absence.")
next_actions.append(
{
"command": "paperforge query-plan",
"reason": "Normalize the query and choose the correct first retrieval command.",
}
)
if plan["recommended_primary"]["command"] != "search":
next_actions.append(
{
"command": f"paperforge {plan['recommended_primary']['command']}",
"reason": "The planning layer recommends a different first command for this query.",
}
)
result = PFResult(ok=True, command="search", version=PF_VERSION, data=data, warnings=warnings, next_actions=next_actions)
except Exception as exc:
result = PFResult(
ok=False, command="search", version=PF_VERSION,

View file

@ -1,6 +1,7 @@
"""Sync command — unified sync through SyncService."""
import argparse
import inspect
import logging
from paperforge import __version__
@ -54,8 +55,21 @@ def run(args: argparse.Namespace) -> int:
from paperforge.services.sync_service import SyncService
svc = SyncService(vault)
result = svc.run(verbose=verbose, json_output=json_output, selection_only=selection_only, index_only=index_only,
prune=prune_flag, prune_force=prune_force)
run_kwargs = {
"verbose": verbose,
"json_output": json_output,
"selection_only": selection_only,
"index_only": index_only,
"prune": prune_flag,
"prune_force": prune_force,
}
try:
sig = inspect.signature(svc.run)
accepted = {name for name in sig.parameters.keys() if name != "self"}
filtered_kwargs = {k: v for k, v in run_kwargs.items() if k in accepted}
except Exception:
filtered_kwargs = run_kwargs
result = svc.run(**filtered_kwargs)
_write_orphan_state(vault, result)

View file

@ -80,7 +80,8 @@ def get_memory_status(vault: Path) -> dict:
def _entry_from_row(row) -> dict:
"""Reconstruct an entry dict from a papers row (sqlite3.Row)."""
entry = {k: row[k] for k in row}
keys = row.keys() if hasattr(row, "keys") else row
entry = {k: row[k] for k in keys}
for key in ("has_pdf", "do_ocr", "analyze"):
if key in entry and entry[key] is not None:
entry[key] = bool(entry[key])

File diff suppressed because one or more lines are too long

View file

@ -66,7 +66,7 @@ const LANG: Record<string, Record<string, string>> = {
feat_installing: 'Installing...',
feat_installing_pkgs: 'Installing {pkgs}...',
feat_key_rejected: 'API key rejected.',
feat_memory_desc: 'The Memory Layer is the core data engine of PaperForge, powered by SQLite. It integrates all literature metadata (papers, assets, aliases, reading events), provides FTS5 full-text search across titles/abstracts/authors/collections, and enables the agent-context and paper-status commands. Always active — no toggle needed.',
feat_memory_desc: 'The Memory Layer is the core data engine of PaperForge, powered by SQLite. It integrates literature metadata (papers, assets, aliases, reading events), provides FTS5 metadata search across titles, abstracts, authors, domains, and collections, and powers agent-context and paper-status. Always active — no toggle needed.',
feat_memory_rebuild_btn: 'Rebuild',
feat_memory_rebuild_done: 'Memory DB rebuilt.',
feat_memory_rebuild_failed: 'Rebuild failed.',
@ -157,7 +157,7 @@ const LANG: Record<string, Record<string, string>> = {
run_in_agent: 'Run in {0}',
runtime_health: 'Runtime Health',
runtime_health_checking: 'Checking...',
runtime_health_desc: 'Check whether the installed paperforge Python package matches the plugin version',
runtime_health_desc: 'Check whether the installed paperforge Python package matches the plugin version and whether the deployed skill contract is current.',
runtime_health_match: 'Match',
runtime_health_mismatch: 'Mismatch',
runtime_health_package_ver: 'Python package v{0}',
@ -261,7 +261,7 @@ const LANG: Record<string, Record<string, string>> = {
feat_installing: '安装中…',
feat_installing_pkgs: '正在安装 {pkgs}...',
feat_key_rejected: 'API Key 被拒绝。',
feat_memory_desc: '记忆层是 PaperForge 的核心数据引擎,基于 SQLite 构建。它整合了所有文献元数据(论文、资源文件、别名、阅读事件),支持 FTS5 全文检索(可搜索标题、摘要、作者、分类),并为 agent-context 和 paper-status 命令提供数据支撑。始终运行,无需手动开启。',
feat_memory_desc: '记忆层是 PaperForge 的核心数据引擎,基于 SQLite 构建。它整合了文献元数据(论文、资源文件、别名、阅读事件),支持 FTS5 元数据检索标题、摘要、作者、domain、collection),并为 agent-context 和 paper-status 命令提供数据支撑。始终运行,无需手动开启。',
feat_memory_rebuild_btn: '重建数据库',
feat_memory_rebuild_done: '记忆数据库重建完成。',
feat_memory_rebuild_failed: '重建失败。',
@ -353,7 +353,7 @@ const LANG: Record<string, Record<string, string>> = {
run_in_agent: '在 {0} 中运行',
runtime_health: '运行时状态',
runtime_health_checking: '正在检测…',
runtime_health_desc: '检查插件与 Python 运行时版本的匹配情况',
runtime_health_desc: '检查插件与 Python 运行时版本的匹配情况,并确认已部署的 skill contract 是否为当前版本。',
runtime_health_match: '匹配',
runtime_health_mismatch: '不匹配',
runtime_health_package_ver: 'Python 包 v{0}',

View file

@ -44,6 +44,14 @@ export interface RuntimeStatus {
action?: string;
}
export interface QueryPlanResult {
ok: boolean;
command: string;
version: string;
data: Record<string, unknown> | null;
error: Record<string, unknown> | null;
}
// ── Cross-platform state ──
let _gitDir: string | null = null;
@ -214,6 +222,44 @@ export function runSubprocess(pythonExe: string, args: string[], cwd: string, ti
});
}
export function runQueryPlan(
pythonExe: string,
extraArgs: string[],
vaultPath: string,
query: string,
intent: "discover" | "content" | "known-paper",
timeout = 20000,
_execFile?: any,
): Promise<QueryPlanResult> {
const exe = _execFile || execFile;
return new Promise((resolve) => {
const args = [...extraArgs, "-m", "paperforge", "--vault", vaultPath, "query-plan", query, "--intent", intent, "--json"];
exe(pythonExe, args, { cwd: vaultPath, timeout, windowsHide: true }, (err: any, stdout: any, stderr: any) => {
if (err) {
resolve({
ok: false,
command: "query-plan",
version: "",
data: null,
error: { message: stderr || err.message || "query-plan failed" },
});
return;
}
try {
resolve(JSON.parse(stdout));
} catch (parseErr: any) {
resolve({
ok: false,
command: "query-plan",
version: "",
data: null,
error: { message: parseErr.message || "Invalid query-plan JSON" },
});
}
});
});
}
// ── Cross-platform Python and BBT detection (macOS/Linux) ──
export function resolveGitDir(): string | null {

View file

@ -0,0 +1,350 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
HIGH_SIGNAL_PRIORITY = [
"doi",
"zotero_key",
"citation_key",
"author",
"year",
"title",
"topic",
]
@dataclass
class QuerySignals:
doi: str | None
zotero_key: str | None
citation_key: str | None
author_tokens: list[str]
year_tokens: list[int]
title_like_tokens: list[str]
content_terms: list[str]
collection_hint: str | None = None
domain_hint: str | None = None
def _dedupe_keep_order(items: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for item in items:
key = item.lower()
if key in seen:
continue
seen.add(key)
out.append(item)
return out
def detect_doi(text: str) -> str | None:
match = re.search(r"(10\.\d{4,9}/[-._;()/:A-Z0-9]+)", text, re.IGNORECASE)
return match.group(1) if match else None
def detect_zotero_key(text: str) -> str | None:
if re.fullmatch(r"[A-Z0-9]{8}", text.strip().upper()):
return text.strip().upper()
return None
def detect_citation_key(text: str) -> str | None:
candidate = text.strip()
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{5,}", candidate):
return None
if not any(ch.isdigit() for ch in candidate) and "-" not in candidate and "_" not in candidate:
return None
return candidate
def extract_years(text: str) -> list[int]:
years = []
for raw in re.findall(r"\b((?:19|20)\d{2})\b", text):
years.append(int(raw))
return years
def tokenize(text: str) -> list[str]:
return re.findall(r"[A-Za-z][A-Za-z0-9.+-]*|[\u4e00-\u9fff]{2,}", text)
def classify_signals(query: str) -> QuerySignals:
doi = detect_doi(query)
zotero_key = detect_zotero_key(query)
citation_key = None if (doi or zotero_key) else detect_citation_key(query)
year_tokens = extract_years(query)
raw_tokens = tokenize(query)
author_tokens: list[str] = []
title_like_tokens: list[str] = []
content_terms: list[str] = []
lowered = [t.lower() for t in raw_tokens]
content_keywords = {
"hz", "v/cm", "v", "ma", "mv", "galvanotaxis", "electrotaxis",
"dc", "ac", "cc", "pemf", "field", "scaffold", "stimulation",
"migration", "chondrocyte", "cartilage", "method", "parameter",
}
for token in raw_tokens:
if any(ch.isdigit() for ch in token):
content_terms.append(token)
continue
low = token.lower()
if low in {"doi", "pmid", "title", "paper", "article"}:
continue
if low in content_keywords:
content_terms.append(token)
title_like_tokens.append(token)
continue
if token[0].isupper() and len(token) >= 3 and not year_tokens:
author_tokens.append(token)
continue
if token[0].isupper() and len(token) >= 3 and year_tokens and not author_tokens:
author_tokens.append(token)
continue
title_like_tokens.append(token)
if not author_tokens and year_tokens:
author_tokens = [t for t in raw_tokens if t.lower() not in content_keywords and len(t) >= 3][:1]
return QuerySignals(
doi=doi,
zotero_key=zotero_key,
citation_key=citation_key,
author_tokens=_dedupe_keep_order(author_tokens),
year_tokens=year_tokens,
title_like_tokens=_dedupe_keep_order(title_like_tokens),
content_terms=_dedupe_keep_order(content_terms),
)
def build_query_plan(query: str, intent: str) -> dict:
signals = classify_signals(query)
query_class = "metadata_topic"
if signals.doi or signals.zotero_key or signals.citation_key:
query_class = "identifier_exact"
elif signals.author_tokens and signals.year_tokens:
query_class = "author_year" if len(signals.title_like_tokens) == 0 else "mixed_query"
elif intent == "content":
query_class = "content_term"
elif signals.content_terms and not signals.author_tokens:
query_class = "metadata_topic"
query_rules: list[str] = []
fallback_plan: list[dict] = []
if query_class == "identifier_exact":
identifier = signals.doi or signals.zotero_key or signals.citation_key or query.strip()
primary = {"command": "paper-context", "args": {"key": identifier}}
query_rules.append("Use exact identifiers directly with paper-context; do not rewrite them.")
fallback_plan.append({"when": "not_found", "action": "fallback_to_paper_status"})
elif intent == "content":
content_query = " ".join(signals.content_terms or signals.title_like_tokens or tokenize(query)[:6]).strip() or query.strip()
primary = {"command": "retrieve", "args": {"query": content_query, "limit": 30}}
query_rules.extend([
"For content lookup, start with retrieve rather than metadata search.",
"Use content-bearing terms, parameters, and method phrases as they would appear in fulltext.",
])
fallback_plan.extend([
{"when": "retrieve_unavailable", "action": "interactive_fulltext_fallback"},
{"when": "zero_results", "action": "interactive_fulltext_fallback"},
])
elif signals.author_tokens and signals.year_tokens:
primary = {
"command": "search",
"args": {
"query": signals.author_tokens[0],
"year_from": min(signals.year_tokens),
"year_to": max(signals.year_tokens),
"limit": 10,
},
}
query_rules.extend([
"For metadata search, prefer author and year over mixed natural-language query strings.",
"When author and year are known, do not include title words in the first-pass search query.",
])
fallback_plan.extend([
{"when": "zero_results", "action": "report_noncanonical_query_risk"},
{"when": "multiple_results", "action": "visually_narrow_with_title_terms"},
])
else:
topic_query = " ".join(signals.title_like_tokens or tokenize(query)[:6]).strip() or query.strip()
primary = {"command": "search", "args": {"query": topic_query, "limit": 30}}
query_rules.extend([
"Use short metadata-facing terms for search: title keywords, author names, domain, or collection.",
"Do not treat search as semantic fulltext discovery.",
])
fallback_plan.extend([
{"when": "zero_results", "action": "report_metadata_miss_not_library_absence"},
{"when": "large_result_set", "action": "narrow_by_domain_year_or_author"},
])
return {
"intent": intent,
"query_class": query_class,
"signals": {
"doi": signals.doi,
"zotero_key": signals.zotero_key,
"citation_key": signals.citation_key,
"author_tokens": signals.author_tokens,
"year_tokens": signals.year_tokens,
"title_like_tokens": signals.title_like_tokens,
"content_terms": signals.content_terms,
},
"signal_priority": HIGH_SIGNAL_PRIORITY,
"recommended_primary": primary,
"query_writing_rules": query_rules,
"fallback_plan": fallback_plan,
}
def enrich_query_plan_with_runtime(plan: dict, vault: Path) -> dict:
from paperforge.embedding import get_embed_status
from paperforge.worker.asset_index import read_index
embed = get_embed_status(vault)
retrieve_available = bool(embed.get("healthy", True) and embed.get("db_exists") and embed.get("chunk_count", 0) > 0)
plan["runtime"] = {
"retrieve_available": retrieve_available,
"vector_status": {
"db_exists": embed.get("db_exists", False),
"chunk_count": embed.get("chunk_count", 0),
"healthy": embed.get("healthy", True),
"error": embed.get("error", ""),
},
}
data = read_index(vault)
items = []
if isinstance(data, dict):
items = data.get("items", [])
elif isinstance(data, list):
items = data
scope = _assess_scope(items, plan["signals"])
plan["scope_assessment"] = scope
if plan["intent"] == "discover" and scope["source"] in {"domain", "collection"}:
if scope["source"] == "domain":
plan["recommended_primary"] = {
"command": "context",
"args": {"domain": scope["label"]},
}
plan["query_writing_rules"].append("When the user names a known domain, prefer context --domain over metadata search.")
else:
plan["recommended_primary"] = {
"command": "context",
"args": {"collection": scope["label"]},
}
plan["query_writing_rules"].append("When the user names a known collection, prefer context --collection over metadata search.")
plan["fallback_plan"].insert(0, {"when": "large_inventory", "action": "summarize_and_offer_narrowing"})
if plan["intent"] == "content":
plan["suggested_modes"] = _suggest_content_fallback_modes(scope)
if plan["intent"] == "content" and not retrieve_available:
plan["interactive_fallback_required"] = True
elif plan["intent"] == "content":
plan["interactive_fallback_required"] = False
return plan
def _assess_scope(items: list[dict], signals: dict) -> dict:
matched_items = items
source = "library"
label = "full library"
title_terms = [str(t) for t in signals.get("title_like_tokens", [])]
content_terms = [str(t) for t in signals.get("content_terms", [])]
query_terms = title_terms + content_terms
domain_counts: dict[str, int] = {}
collection_counts: dict[str, int] = {}
for entry in items:
domain = entry.get("domain", "")
if domain:
domain_counts[domain] = domain_counts.get(domain, 0) + 1
for collection in entry.get("collections", []) or []:
if isinstance(collection, str) and collection:
collection_counts[collection] = collection_counts.get(collection, 0) + 1
normalized_terms = [str(t).strip().lower() for t in query_terms if str(t).strip()]
matched_domain = next(
(
d for d in domain_counts
if any(str(d).strip().lower() == token or token in str(d).strip().lower() for token in normalized_terms)
),
None,
)
if matched_domain:
matched_items = [entry for entry in items if entry.get("domain") == matched_domain]
source = "domain"
label = matched_domain
else:
matched_collection = next(
(
c for c in collection_counts
if any(
str(c).strip().lower() == token
or token in str(c).strip().lower()
or any(part.strip().lower() == token for part in str(c).split("/"))
for token in normalized_terms
)
),
None,
)
if matched_collection:
matched_items = [
entry for entry in items
if any(isinstance(col, str) and col.startswith(matched_collection) for col in entry.get("collections", []) or [])
]
source = "collection"
label = matched_collection
estimated = len(matched_items)
fulltext_ready = sum(1 for entry in matched_items if entry.get("lifecycle") in {"fulltext_ready", "deep_read_done", "ai_context_ready"})
if estimated <= 20:
recommended_mode = "rg_now"
elif estimated <= 60:
recommended_mode = "rg_with_warning"
else:
recommended_mode = "ask_user_before_broad_grep"
return {
"source": source,
"label": label,
"estimated_paper_count": estimated,
"fulltext_ready_count": fulltext_ready,
"recommended_mode": recommended_mode,
}
def _suggest_content_fallback_modes(scope: dict) -> list[dict]:
source = scope.get("source", "library")
label = scope.get("label", "current scope")
modes = [
{
"mode": "fulltext_rg_or_grep",
"description": f"Run literal fulltext search within {label} ({source}) for exact verification.",
},
{
"mode": "metadata_narrow_then_fulltext",
"description": "Use metadata search to narrow candidate papers first, then run fulltext verification.",
},
]
if scope.get("recommended_mode") == "ask_user_before_broad_grep":
modes.insert(
0,
{
"mode": "ask_for_scope_limit",
"description": "Ask the user to limit collection, domain, or year before broad fulltext search.",
},
)
return modes

View file

@ -225,6 +225,7 @@ class SyncService:
_export_code = "BBT_EXPORT_INVALID"
selection_result: dict = {"new": 0, "updated": 0, "skipped": 0, "failed": 0, "errors": []}
_prune_preview: list[dict] = []
# ── Phase 1: Select ──
if not index_only:
@ -319,9 +320,6 @@ class SyncService:
is_ok = selection_result.get("failed", 0) == 0 and not selection_result.get("errors")
if not selection_only:
_prune_preview = locals().get("_prune_preview", [])
result = PFResult(
ok=is_ok,
command="sync",

View file

@ -8,7 +8,8 @@ description: >
"讨论" "记录阅读" "记录工作" "总结会话" "提取方法论"
"记一下" "保存这次" "找证据" "找75 Hz" "找支持" "collection" "库里".
source: paperforge
version: 1.5.9
skill_version: 2026-05-27.1
skill_api_version: 2
---
# PaperForge — Research Memory Runtime
@ -16,6 +17,14 @@ version: 1.5.9
PaperForge 将文献、阅读痕迹、工作过程、方法论和产物
组织成可检索、可复核、可由 agent 调用的研究记忆。
**触发原则:只要用户意图涉及以下任一类,必须调用 paperforge skill**
- 文献库 / vault 中的论文检索、collection / domain 盘点
- 已知论文的定位、阅读、问答、/pf-paper
- 正文内容、术语、参数、方法、支持证据的查找
- 单篇论文精读、/pf-deep
- PaperForge 阅读笔记、项目日志、方法论卡片保存
---
## 1. Bootstrap — 必须先执行
@ -46,7 +55,7 @@ bootstrap 现在也返回一个 `capabilities` 块rg, semantic, metadata 等
**进入任何 molecule 之前,必须完成以下检查。标记为 `[x]` 后才可前进。**
- [ ] **bootstrap completed**`$VAULT`、`$PYTHON`、`$LIT_DIR`、`$SKILL_DIR` 已定义
- [ ] **version match**`$PYTHON -m paperforge --version` 输出的版本号与 SKILL.md frontmatter 的 `version` 字段一致;不一致时提示用户运行 `paperforge update`
- [ ] **skill contract**bootstrap 已返回 `plugin_version`、`skill_version`、`skill_api_version`;若 `skill_api_version` 不兼容,提示用户运行 `paperforge update`
- [ ] **capabilities**:已读取 `capabilities` 块中的 `rg`、`metadata_search`、`paper_context`、`semantic_enabled`、`semantic_ready`
- [ ] **runtime-health passed**`safe_read`、`safe_write` 已知vector 状态已知
- [ ] **intent identified**:用户意图已映射到一个顶层 research intent

View file

@ -24,6 +24,15 @@
- **范围**domain如"骨科")、不指定=全库
- **过滤条件**OCR 状态、年份范围(`--year-from`/`--year-to`、lifecycle
**先调用 query-plan**
```bash
$PYTHON -m paperforge --vault "$VAULT" query-plan "<user_query>" --intent discover --json
```
执行 `recommended_primary.command` 与其 `args`。不要把作者、年份、标题词、主题词混成一条 search query。
如果 `query-plan` 判断是 `author + year``mixed_query`,第一轮只用作者 + 年份。
### Step 2: 多臂搜索策略
打开 `atoms/retrieval-routing.md` 参照 Ladder A。
@ -69,6 +78,7 @@ $PYTHON -m paperforge --vault "$VAULT" search "<query>" --json --limit 30 \
- FTS5 搜索标题、摘要、作者、期刊、domain、collection 路径
- 返回 JSON`data.matches[]` 包含 `zotero_key`、`title`、`year`、`first_author` 等
- **作者+年份搜索**query 放作者名,年份放 `--year-from`/`--year-to`(如 `search "Brighton" --year-from 1983 --year-to 1983`
- 不要把标题词塞进第一轮 author+year 查询;标题词只用于人工二次缩小
#### Arm 3 — Collection/Domain 列举(完整列表,不截断)
@ -103,6 +113,12 @@ $PYTHON -m paperforge --vault "$VAULT" context --domain "<domain>" --json
然后只运行 Arm 2或 Arm 3 如果是 collection/domain 查询)。
#### 当 `search` 0 结果时
- 不要把 raw 0 结果直接解释成“库里没有”
- 先检查 query-plan 是否已给出规范化写法
- 只有规范化后的第一轮也 0 结果时,才能报告“未找到匹配”
### Step 3: Top-hit 富化(`paperforge paper-context`
对候选列表中的 **前 10 篇**(如果超过 10 篇,只富化前 10`paper-context` 获取详细状态:

View file

@ -25,6 +25,14 @@
- **范围限制**:是否限定特定 domain、作者、年份
- **证据类型**:统计结果、方法引用、临床发现、机制解释等
**先调用 query-plan**
```bash
$PYTHON -m paperforge --vault "$VAULT" query-plan "<user_query>" --intent content --json
```
如果 `query-plan` 推荐 `retrieve`,直接从 `retrieve` 开始,不要先走一遍 paper discovery。
### Step 2: 检索梯级选择(`atoms/retrieval-routing.md`
**先检查 `retrieve` 是否可用:**
@ -44,7 +52,9 @@ $PYTHON -m paperforge --vault "$VAULT" embed status --json
- 如需精确定位验证,再用 `rg` / `grep` 在论文全文中确认
2. **Ladder B2**(备选,无 `retrieve` 但有 `rg`-- 元数据→全文 grep
- 先用 `paperforge search` 生成元数据候选集
- 默认不要静默降级到 metadata search
- 先根据 query-plan 返回向用户解释retrieve 不可用 / 0 结果,需要选择 fallback 模式
- 如果用户同意 metadata 缩一轮,再用 `paperforge search` 生成候选集
- 用 `runtime-health``paper-context` 筛选有 OCR/全文的论文
- 在解析后的全文中运行 `rg` 定位匹配片段

View file

@ -27,16 +27,28 @@
用户可能给 zotero_key、DOI、标题关键词、作者+年份。按以下方式查找:
**先调用 query-plan不要自己发明第一条 query**
```bash
$PYTHON -m paperforge --vault "$VAULT" query-plan "<user_query>" --intent known-paper --json
```
执行 `recommended_primary.command` 与其 `args`。规则:
- `doi` / `zotero_key` / `citation_key` → 直接 `paper-context`
- `author + year` → 第一轮只跑作者 + 年份过滤,不加标题词
- 模糊标题 / 主题词 → `search``paper-status`
**如果知道 zotero_key**
```bash
$PYTHON -m paperforge --vault "$VAULT" paper-context <zotero_key> --json
```
**如果知道 DOI**
**如果知道 DOI / citation_key**
```bash
$PYTHON -m paperforge --vault "$VAULT" paper-context <DOI> --json
$PYTHON -m paperforge --vault "$VAULT" paper-context <DOI_OR_CITATION_KEY> --json
```
**如果知道 作者+年份:**
@ -65,7 +77,7 @@ $PYTHON -m paperforge --vault "$VAULT" search "<query>" --json --limit 5
无论哪种方式命中,取 `zotero_key``paper-context` 获取完整信息。
如果多候选,列出让用户选。
如果无结果,告知用户并停止
如果无结果,不要直接说“库里没有”;先说明这是规范化后的无结果,再告知用户未找到
### Step 2: 加载文献内容

View file

@ -31,15 +31,65 @@ If anything fails: ok=false, error explains why.
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
try:
import paperforge
SKILL_VERSION = paperforge.__version__
except Exception:
SKILL_VERSION = "unknown"
REPO_ROOT = Path(__file__).resolve().parents[4]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
def _read_plugin_version() -> str:
init_py = REPO_ROOT / "paperforge" / "__init__.py"
if not init_py.exists():
return "unknown"
try:
text = init_py.read_text(encoding="utf-8")
except Exception:
return "unknown"
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text)
return match.group(1) if match else "unknown"
PLUGIN_VERSION = _read_plugin_version()
def _parse_skill_metadata() -> tuple[str, int | None]:
skill_md = Path(__file__).resolve().parent.parent / "SKILL.md"
if not skill_md.exists():
return ("unknown", None)
try:
text = skill_md.read_text(encoding="utf-8")
except Exception:
return ("unknown", None)
if not text.startswith("---"):
return ("unknown", None)
lines = text.splitlines()
skill_version = "unknown"
skill_api_version: int | None = None
in_frontmatter = False
for line in lines:
if line.strip() == "---":
if not in_frontmatter:
in_frontmatter = True
continue
break
if not in_frontmatter or ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key == "skill_version":
skill_version = value
elif key == "skill_api_version":
try:
skill_api_version = int(value)
except ValueError:
pass
return (skill_version, skill_api_version)
def _find_paperforge_json(start: Path) -> Path | None:
@ -164,6 +214,7 @@ def main() -> None:
args = p.parse_args()
result: dict = {"ok": False}
skill_version, skill_api_version = _parse_skill_metadata()
# --- 1. Find vault ---
if args.vault:
@ -294,7 +345,9 @@ def main() -> None:
}
result["ok"] = True
result["skill_version"] = SKILL_VERSION
result["plugin_version"] = PLUGIN_VERSION
result["skill_version"] = skill_version
result["skill_api_version"] = skill_api_version
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)

View file

@ -61,7 +61,7 @@ def code_block_lines(content: str) -> list[str]:
@pytest.fixture
def command_docs() -> dict[str, Path]:
base = REPO_ROOT / "command"
base = REPO_ROOT / "paperforge" / "command_files"
return {
"pf-status": base / "pf-status.md",
"pf-sync": base / "pf-sync.md",

View file

@ -0,0 +1,56 @@
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
VAULT = Path(r"D:\L\OB\Literature-hub")
pytestmark = pytest.mark.skipif(not VAULT.exists(), reason="Local Literature-hub vault not available")
def _run_json(*args: str) -> dict:
result = subprocess.run(
[sys.executable, "-m", "paperforge", "--vault", str(VAULT), *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=30,
check=True,
)
return json.loads(result.stdout)
def test_mixed_author_year_query_returns_search_diagnostic() -> None:
payload = _run_json("search", "Lin 2024 Electrical Stimulation", "--json", "--limit", "10")
assert payload["ok"] is True
assert payload["data"]["count"] == 0
diagnostic = payload["data"]["query_diagnostic"]
assert diagnostic["query_class"] == "mixed_query"
assert diagnostic["recommended_primary"]["args"]["query"] == "Lin"
assert diagnostic["recommended_primary"]["args"]["year_from"] == 2024
def test_content_query_prefers_retrieve() -> None:
payload = _run_json("query-plan", "galvanotaxis", "--intent", "content", "--json")
assert payload["ok"] is True
assert payload["data"]["recommended_primary"]["command"] == "retrieve"
def test_domain_inventory_query_prefers_context() -> None:
payload = _run_json("query-plan", "骨科 里有什么", "--intent", "discover", "--json")
assert payload["ok"] is True
assert payload["data"]["recommended_primary"]["command"] == "context"
assert payload["data"]["recommended_primary"]["args"]["domain"] == "骨科"
def test_doi_paper_context_resolves_known_paper() -> None:
payload = _run_json("paper-context", "10.1016/j.heliyon.2024.e38112", "--json")
assert payload["ok"] is True
assert payload["data"]["paper"]["zotero_key"] == "L6ALWJFP"

View file

@ -76,7 +76,13 @@ def test_bootstrap_capabilities_contract(tmp_path: Path) -> None:
assert "semantic_ready" in caps, "capabilities.semantic_ready missing"
assert isinstance(caps["semantic_ready"], bool), "capabilities.semantic_ready must be bool"
# Skill version
pv = output.get("plugin_version")
assert pv is not None, "plugin_version missing from bootstrap output"
assert isinstance(pv, str) and pv, f"plugin_version invalid: {pv}"
sv = output.get("skill_version")
assert sv is not None, "skill_version missing from bootstrap output"
assert isinstance(sv, str) and sv != "unknown", f"skill_version invalid: {sv}"
assert isinstance(sv, str) and sv, f"skill_version invalid: {sv}"
sav = output.get("skill_api_version")
assert sav is None or isinstance(sav, int), f"skill_api_version invalid: {sav}"

View file

@ -0,0 +1,74 @@
from __future__ import annotations
import json
from pathlib import Path
from paperforge.commands import retrieve as retrieve_command
from paperforge.commands import search as search_command
class _Args:
def __init__(self, vault_path: Path) -> None:
self.vault_path = vault_path
self.query = "Lin 2024 Electrical Stimulation"
self.json = True
self.limit = 10
self.domain = None
self.year_from = None
self.year_to = None
self.ocr = None
self.deep = None
self.lifecycle = None
self.next_step = None
self.expand = True
class _Conn:
def close(self) -> None:
return None
def test_search_zero_results_emits_query_diagnostic(monkeypatch, tmp_path: Path, capsys) -> None:
db_path = tmp_path / "paperforge.db"
db_path.write_text("", encoding="utf-8")
monkeypatch.setattr(search_command, "get_memory_db_path", lambda vault: db_path)
monkeypatch.setattr(search_command, "get_connection", lambda db_path, read_only=True: _Conn())
monkeypatch.setattr(search_command, "search_papers", lambda *args, **kwargs: [])
args = _Args(tmp_path)
assert search_command.run(args) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["ok"] is True
assert payload["data"]["count"] == 0
assert "query_diagnostic" in payload["data"]
assert payload["warnings"]
def test_retrieve_unavailable_emits_suggested_modes(monkeypatch, tmp_path: Path, capsys) -> None:
monkeypatch.setattr("paperforge.embedding.get_embed_status", lambda vault: {"healthy": True, "chunk_count": 0, "db_exists": False, "error": ""})
args = _Args(tmp_path)
args.query = "galvanotaxis"
assert retrieve_command.run(args) == 1
payload = json.loads(capsys.readouterr().out)
assert payload["ok"] is False
assert payload["data"]["interactive_fallback_required"] is True
assert payload["data"]["suggested_modes"]
def test_retrieve_low_confidence_hits_emit_warning(monkeypatch, tmp_path: Path, capsys) -> None:
monkeypatch.setattr("paperforge.embedding.get_embed_status", lambda vault: {"healthy": True, "chunk_count": 5, "db_exists": True, "error": ""})
monkeypatch.setattr(retrieve_command, "retrieve_chunks", lambda vault, query, limit=5, expand=True: [
{"paper_id": "AAA11111", "chunk_text": "[Figure]", "score": 0.58},
{"paper_id": "AAA11111", "chunk_text": "### Keywords", "score": 0.57},
{"paper_id": "AAA11111", "chunk_text": "None.", "score": 0.56},
])
monkeypatch.setattr(retrieve_command, "get_memory_db_path", lambda vault: tmp_path / "missing.db")
args = _Args(tmp_path)
args.query = "unlikelynonexistenttermxyz"
assert retrieve_command.run(args) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["ok"] is True
assert payload["warnings"]
assert payload["data"]["query_diagnostic"]["interactive_fallback_required"] is True

78
tests/test_query_plan.py Normal file
View file

@ -0,0 +1,78 @@
from __future__ import annotations
from pathlib import Path
import pytest
from paperforge.query_planning import build_query_plan, enrich_query_plan_with_runtime
from paperforge.commands.query_plan import run as query_plan_run
class _Args:
def __init__(self, query: str, intent: str, vault_path: Path, json: bool = True) -> None:
self.query = query
self.intent = intent
self.vault_path = vault_path
self.json = json
def test_query_plan_identifier_exact_prefers_paper_context() -> None:
plan = build_query_plan("10.1016/j.heliyon.2024.e38112", "known-paper")
assert plan["query_class"] == "identifier_exact"
assert plan["recommended_primary"]["command"] == "paper-context"
def test_query_plan_mixed_author_year_rewrites_to_author_year_search() -> None:
plan = build_query_plan("Lin 2024 Electrical Stimulation", "discover")
primary = plan["recommended_primary"]
assert plan["query_class"] == "mixed_query"
assert primary["command"] == "search"
assert primary["args"]["query"] == "Lin"
assert primary["args"]["year_from"] == 2024
assert primary["args"]["year_to"] == 2024
def test_query_plan_content_prefers_retrieve() -> None:
plan = build_query_plan("galvanotaxis", "content")
assert plan["query_class"] == "content_term"
assert plan["recommended_primary"]["command"] == "retrieve"
assert any(step["action"] == "interactive_fulltext_fallback" for step in plan["fallback_plan"])
def test_query_plan_runtime_enrichment_without_retrieve(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
monkeypatch.setattr(
"paperforge.commands.query_plan.enrich_query_plan_with_runtime",
lambda plan, vault: {
**plan,
"runtime": {"retrieve_available": False, "vector_status": {"db_exists": False, "chunk_count": 0, "healthy": True, "error": ""}},
"scope_assessment": {
"source": "library",
"label": "full library",
"estimated_paper_count": 120,
"fulltext_ready_count": 80,
"recommended_mode": "ask_user_before_broad_grep",
},
"interactive_fallback_required": True,
"suggested_modes": [
{"mode": "ask_for_scope_limit", "description": "Ask first."},
{"mode": "fulltext_rg_or_grep", "description": "Search fulltext."},
],
},
)
args = _Args("galvanotaxis", "content", tmp_path)
assert query_plan_run(args) == 0
out = capsys.readouterr().out
assert '"interactive_fallback_required": true' in out.lower()
assert '"mode": "ask_for_scope_limit"' in out
def test_query_plan_discover_prefers_context_for_known_domain(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
plan = build_query_plan("骨科 里有什么", "discover")
monkeypatch.setattr("paperforge.embedding.get_embed_status", lambda vault: {"db_exists": False, "chunk_count": 0, "healthy": True, "error": ""})
monkeypatch.setattr(
"paperforge.worker.asset_index.read_index",
lambda vault: {"items": [{"domain": "骨科", "lifecycle": "fulltext_ready", "collections": []} for _ in range(5)]},
)
enriched = enrich_query_plan_with_runtime(plan, tmp_path)
assert enriched["recommended_primary"]["command"] == "context"
assert enriched["recommended_primary"]["args"]["domain"] == "骨科"

View file

@ -62,6 +62,12 @@ def test_retrieval_routing_has_semantic_optional_rule() -> None:
assert "optional" in text.lower() or "supplementary" in text.lower() or "only for candidate expansion" in text
def test_skill_md_has_intent_level_trigger_language() -> None:
text = SKILL_MD.read_text(encoding="utf-8")
assert "必须调用 paperforge skill" in text
assert "collection / domain" in text or "正文内容" in text
# --- Group 4: Molecule output shape contracts ---
def test_discover_papers_output_is_paper_list() -> None:
@ -72,3 +78,10 @@ def test_discover_papers_output_is_paper_list() -> None:
def test_find_evidence_output_is_grouped_hits() -> None:
text = EVIDENCE_MD.read_text(encoding="utf-8")
assert "group" in text.lower() or "evidence hit" in text.lower() or "snippet" in text.lower()
def test_molecules_reference_query_plan() -> None:
discover_text = DISCOVER_MD.read_text(encoding="utf-8")
evidence_text = EVIDENCE_MD.read_text(encoding="utf-8")
assert "query-plan" in discover_text
assert "query-plan" in evidence_text