@blxzer/cursor-trellis 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/LICENSE +235 -0
- package/README.md +9 -0
- package/bin/smart-search.js +79 -0
- package/bin/trellis.js +3 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +258 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/commands/channel/adapters/claude.d.ts +29 -0
- package/dist/commands/channel/adapters/claude.d.ts.map +1 -0
- package/dist/commands/channel/adapters/claude.js +203 -0
- package/dist/commands/channel/adapters/claude.js.map +1 -0
- package/dist/commands/channel/adapters/codex.d.ts +85 -0
- package/dist/commands/channel/adapters/codex.d.ts.map +1 -0
- package/dist/commands/channel/adapters/codex.js +505 -0
- package/dist/commands/channel/adapters/codex.js.map +1 -0
- package/dist/commands/channel/adapters/index.d.ts +84 -0
- package/dist/commands/channel/adapters/index.d.ts.map +1 -0
- package/dist/commands/channel/adapters/index.js +115 -0
- package/dist/commands/channel/adapters/index.js.map +1 -0
- package/dist/commands/channel/adapters/types.d.ts +33 -0
- package/dist/commands/channel/adapters/types.d.ts.map +1 -0
- package/dist/commands/channel/adapters/types.js +2 -0
- package/dist/commands/channel/adapters/types.js.map +1 -0
- package/dist/commands/channel/agent-loader.d.ts +32 -0
- package/dist/commands/channel/agent-loader.d.ts.map +1 -0
- package/dist/commands/channel/agent-loader.js +154 -0
- package/dist/commands/channel/agent-loader.js.map +1 -0
- package/dist/commands/channel/context-loader.d.ts +26 -0
- package/dist/commands/channel/context-loader.d.ts.map +1 -0
- package/dist/commands/channel/context-loader.js +290 -0
- package/dist/commands/channel/context-loader.js.map +1 -0
- package/dist/commands/channel/context.d.ts +16 -0
- package/dist/commands/channel/context.d.ts.map +1 -0
- package/dist/commands/channel/context.js +83 -0
- package/dist/commands/channel/context.js.map +1 -0
- package/dist/commands/channel/create.d.ts +27 -0
- package/dist/commands/channel/create.d.ts.map +1 -0
- package/dist/commands/channel/create.js +39 -0
- package/dist/commands/channel/create.js.map +1 -0
- package/dist/commands/channel/dev-parse-trace.d.ts +14 -0
- package/dist/commands/channel/dev-parse-trace.d.ts.map +1 -0
- package/dist/commands/channel/dev-parse-trace.js +70 -0
- package/dist/commands/channel/dev-parse-trace.js.map +1 -0
- package/dist/commands/channel/guard.d.ts +150 -0
- package/dist/commands/channel/guard.d.ts.map +1 -0
- package/dist/commands/channel/guard.js +474 -0
- package/dist/commands/channel/guard.js.map +1 -0
- package/dist/commands/channel/index.d.ts +3 -0
- package/dist/commands/channel/index.d.ts.map +1 -0
- package/dist/commands/channel/index.js +531 -0
- package/dist/commands/channel/index.js.map +1 -0
- package/dist/commands/channel/interrupt.d.ts +10 -0
- package/dist/commands/channel/interrupt.d.ts.map +1 -0
- package/dist/commands/channel/interrupt.js +22 -0
- package/dist/commands/channel/interrupt.js.map +1 -0
- package/dist/commands/channel/kill.d.ts +7 -0
- package/dist/commands/channel/kill.d.ts.map +1 -0
- package/dist/commands/channel/kill.js +121 -0
- package/dist/commands/channel/kill.js.map +1 -0
- package/dist/commands/channel/list.d.ts +17 -0
- package/dist/commands/channel/list.d.ts.map +1 -0
- package/dist/commands/channel/list.js +233 -0
- package/dist/commands/channel/list.js.map +1 -0
- package/dist/commands/channel/messages.d.ts +15 -0
- package/dist/commands/channel/messages.d.ts.map +1 -0
- package/dist/commands/channel/messages.js +245 -0
- package/dist/commands/channel/messages.js.map +1 -0
- package/dist/commands/channel/rm.d.ts +27 -0
- package/dist/commands/channel/rm.d.ts.map +1 -0
- package/dist/commands/channel/rm.js +216 -0
- package/dist/commands/channel/rm.js.map +1 -0
- package/dist/commands/channel/run.d.ts +30 -0
- package/dist/commands/channel/run.d.ts.map +1 -0
- package/dist/commands/channel/run.js +130 -0
- package/dist/commands/channel/run.js.map +1 -0
- package/dist/commands/channel/send.d.ts +11 -0
- package/dist/commands/channel/send.d.ts.map +1 -0
- package/dist/commands/channel/send.js +24 -0
- package/dist/commands/channel/send.js.map +1 -0
- package/dist/commands/channel/spawn.d.ts +40 -0
- package/dist/commands/channel/spawn.d.ts.map +1 -0
- package/dist/commands/channel/spawn.js +244 -0
- package/dist/commands/channel/spawn.js.map +1 -0
- package/dist/commands/channel/store/events.d.ts +39 -0
- package/dist/commands/channel/store/events.d.ts.map +1 -0
- package/dist/commands/channel/store/events.js +87 -0
- package/dist/commands/channel/store/events.js.map +1 -0
- package/dist/commands/channel/store/filter.d.ts +3 -0
- package/dist/commands/channel/store/filter.d.ts.map +1 -0
- package/dist/commands/channel/store/filter.js +2 -0
- package/dist/commands/channel/store/filter.js.map +1 -0
- package/dist/commands/channel/store/lock.d.ts +23 -0
- package/dist/commands/channel/store/lock.d.ts.map +1 -0
- package/dist/commands/channel/store/lock.js +99 -0
- package/dist/commands/channel/store/lock.js.map +1 -0
- package/dist/commands/channel/store/paths.d.ts +63 -0
- package/dist/commands/channel/store/paths.d.ts.map +1 -0
- package/dist/commands/channel/store/paths.js +246 -0
- package/dist/commands/channel/store/paths.js.map +1 -0
- package/dist/commands/channel/store/schema.d.ts +27 -0
- package/dist/commands/channel/store/schema.d.ts.map +1 -0
- package/dist/commands/channel/store/schema.js +34 -0
- package/dist/commands/channel/store/schema.js.map +1 -0
- package/dist/commands/channel/store/thread-state.d.ts +5 -0
- package/dist/commands/channel/store/thread-state.d.ts.map +1 -0
- package/dist/commands/channel/store/thread-state.js +16 -0
- package/dist/commands/channel/store/thread-state.js.map +1 -0
- package/dist/commands/channel/store/watch.d.ts +19 -0
- package/dist/commands/channel/store/watch.d.ts.map +1 -0
- package/dist/commands/channel/store/watch.js +146 -0
- package/dist/commands/channel/store/watch.js.map +1 -0
- package/dist/commands/channel/supervisor/idle.d.ts +46 -0
- package/dist/commands/channel/supervisor/idle.d.ts.map +1 -0
- package/dist/commands/channel/supervisor/idle.js +72 -0
- package/dist/commands/channel/supervisor/idle.js.map +1 -0
- package/dist/commands/channel/supervisor/inbox.d.ts +30 -0
- package/dist/commands/channel/supervisor/inbox.d.ts.map +1 -0
- package/dist/commands/channel/supervisor/inbox.js +160 -0
- package/dist/commands/channel/supervisor/inbox.js.map +1 -0
- package/dist/commands/channel/supervisor/shutdown.d.ts +68 -0
- package/dist/commands/channel/supervisor/shutdown.d.ts.map +1 -0
- package/dist/commands/channel/supervisor/shutdown.js +146 -0
- package/dist/commands/channel/supervisor/shutdown.js.map +1 -0
- package/dist/commands/channel/supervisor/stdout.d.ts +51 -0
- package/dist/commands/channel/supervisor/stdout.d.ts.map +1 -0
- package/dist/commands/channel/supervisor/stdout.js +121 -0
- package/dist/commands/channel/supervisor/stdout.js.map +1 -0
- package/dist/commands/channel/supervisor/turns.d.ts +31 -0
- package/dist/commands/channel/supervisor/turns.d.ts.map +1 -0
- package/dist/commands/channel/supervisor/turns.js +45 -0
- package/dist/commands/channel/supervisor/turns.js.map +1 -0
- package/dist/commands/channel/supervisor/warning.d.ts +48 -0
- package/dist/commands/channel/supervisor/warning.d.ts.map +1 -0
- package/dist/commands/channel/supervisor/warning.js +77 -0
- package/dist/commands/channel/supervisor/warning.js.map +1 -0
- package/dist/commands/channel/supervisor.d.ts +59 -0
- package/dist/commands/channel/supervisor.d.ts.map +1 -0
- package/dist/commands/channel/supervisor.js +344 -0
- package/dist/commands/channel/supervisor.js.map +1 -0
- package/dist/commands/channel/text-body.d.ts +13 -0
- package/dist/commands/channel/text-body.d.ts.map +1 -0
- package/dist/commands/channel/text-body.js +47 -0
- package/dist/commands/channel/text-body.js.map +1 -0
- package/dist/commands/channel/threads.d.ts +39 -0
- package/dist/commands/channel/threads.d.ts.map +1 -0
- package/dist/commands/channel/threads.js +106 -0
- package/dist/commands/channel/threads.js.map +1 -0
- package/dist/commands/channel/title.d.ts +12 -0
- package/dist/commands/channel/title.d.ts.map +1 -0
- package/dist/commands/channel/title.js +24 -0
- package/dist/commands/channel/title.js.map +1 -0
- package/dist/commands/channel/wait.d.ts +17 -0
- package/dist/commands/channel/wait.d.ts.map +1 -0
- package/dist/commands/channel/wait.js +75 -0
- package/dist/commands/channel/wait.js.map +1 -0
- package/dist/commands/init.d.ts +62 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +1656 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/mem.d.ts +30 -0
- package/dist/commands/mem.d.ts.map +1 -0
- package/dist/commands/mem.js +424 -0
- package/dist/commands/mem.js.map +1 -0
- package/dist/commands/rollout.d.ts +34 -0
- package/dist/commands/rollout.d.ts.map +1 -0
- package/dist/commands/rollout.js +102 -0
- package/dist/commands/rollout.js.map +1 -0
- package/dist/commands/uninstall.d.ts +27 -0
- package/dist/commands/uninstall.d.ts.map +1 -0
- package/dist/commands/uninstall.js +339 -0
- package/dist/commands/uninstall.js.map +1 -0
- package/dist/commands/update.d.ts +80 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +2185 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/upgrade.d.ts +28 -0
- package/dist/commands/upgrade.d.ts.map +1 -0
- package/dist/commands/upgrade.js +84 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/commands/workflow.d.ts +35 -0
- package/dist/commands/workflow.d.ts.map +1 -0
- package/dist/commands/workflow.js +219 -0
- package/dist/commands/workflow.js.map +1 -0
- package/dist/configurators/antigravity.d.ts +7 -0
- package/dist/configurators/antigravity.d.ts.map +1 -0
- package/dist/configurators/antigravity.js +19 -0
- package/dist/configurators/antigravity.js.map +1 -0
- package/dist/configurators/claude.d.ts +9 -0
- package/dist/configurators/claude.d.ts.map +1 -0
- package/dist/configurators/claude.js +72 -0
- package/dist/configurators/claude.js.map +1 -0
- package/dist/configurators/codebuddy.d.ts +10 -0
- package/dist/configurators/codebuddy.d.ts.map +1 -0
- package/dist/configurators/codebuddy.js +30 -0
- package/dist/configurators/codebuddy.js.map +1 -0
- package/dist/configurators/codex.d.ts +8 -0
- package/dist/configurators/codex.d.ts.map +1 -0
- package/dist/configurators/codex.js +87 -0
- package/dist/configurators/codex.js.map +1 -0
- package/dist/configurators/copilot.d.ts +10 -0
- package/dist/configurators/copilot.d.ts.map +1 -0
- package/dist/configurators/copilot.js +51 -0
- package/dist/configurators/copilot.js.map +1 -0
- package/dist/configurators/cursor.d.ts +20 -0
- package/dist/configurators/cursor.d.ts.map +1 -0
- package/dist/configurators/cursor.js +49 -0
- package/dist/configurators/cursor.js.map +1 -0
- package/dist/configurators/cursor2plus-local.d.ts +6 -0
- package/dist/configurators/cursor2plus-local.d.ts.map +1 -0
- package/dist/configurators/cursor2plus-local.js +20 -0
- package/dist/configurators/cursor2plus-local.js.map +1 -0
- package/dist/configurators/droid.d.ts +10 -0
- package/dist/configurators/droid.d.ts.map +1 -0
- package/dist/configurators/droid.js +30 -0
- package/dist/configurators/droid.js.map +1 -0
- package/dist/configurators/gemini.d.ts +16 -0
- package/dist/configurators/gemini.d.ts.map +1 -0
- package/dist/configurators/gemini.js +38 -0
- package/dist/configurators/gemini.js.map +1 -0
- package/dist/configurators/index.d.ts +69 -0
- package/dist/configurators/index.d.ts.map +1 -0
- package/dist/configurators/index.js +395 -0
- package/dist/configurators/index.js.map +1 -0
- package/dist/configurators/kilo.d.ts +7 -0
- package/dist/configurators/kilo.d.ts.map +1 -0
- package/dist/configurators/kilo.js +19 -0
- package/dist/configurators/kilo.js.map +1 -0
- package/dist/configurators/kiro.d.ts +8 -0
- package/dist/configurators/kiro.d.ts.map +1 -0
- package/dist/configurators/kiro.js +24 -0
- package/dist/configurators/kiro.js.map +1 -0
- package/dist/configurators/opencode.d.ts +14 -0
- package/dist/configurators/opencode.d.ts.map +1 -0
- package/dist/configurators/opencode.js +96 -0
- package/dist/configurators/opencode.js.map +1 -0
- package/dist/configurators/pi.d.ts +3 -0
- package/dist/configurators/pi.d.ts.map +1 -0
- package/dist/configurators/pi.js +45 -0
- package/dist/configurators/pi.js.map +1 -0
- package/dist/configurators/qoder.d.ts +11 -0
- package/dist/configurators/qoder.d.ts.map +1 -0
- package/dist/configurators/qoder.js +31 -0
- package/dist/configurators/qoder.js.map +1 -0
- package/dist/configurators/shared.d.ts +183 -0
- package/dist/configurators/shared.d.ts.map +1 -0
- package/dist/configurators/shared.js +557 -0
- package/dist/configurators/shared.js.map +1 -0
- package/dist/configurators/windsurf.d.ts +7 -0
- package/dist/configurators/windsurf.d.ts.map +1 -0
- package/dist/configurators/windsurf.js +19 -0
- package/dist/configurators/windsurf.js.map +1 -0
- package/dist/configurators/workflow.d.ts +46 -0
- package/dist/configurators/workflow.d.ts.map +1 -0
- package/dist/configurators/workflow.js +180 -0
- package/dist/configurators/workflow.js.map +1 -0
- package/dist/constants/paths.d.ts +70 -0
- package/dist/constants/paths.d.ts.map +1 -0
- package/dist/constants/paths.js +79 -0
- package/dist/constants/paths.js.map +1 -0
- package/dist/constants/version.d.ts +9 -0
- package/dist/constants/version.d.ts.map +1 -0
- package/dist/constants/version.js +15 -0
- package/dist/constants/version.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/index.d.ts +62 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +187 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/migrations/manifests/0.1.0.json +9 -0
- package/dist/templates/claude/agents/trellis-check.md +124 -0
- package/dist/templates/claude/agents/trellis-implement.md +110 -0
- package/dist/templates/claude/agents/trellis-research.md +137 -0
- package/dist/templates/claude/index.d.ts +22 -0
- package/dist/templates/claude/index.d.ts.map +1 -0
- package/dist/templates/claude/index.js +46 -0
- package/dist/templates/claude/index.js.map +1 -0
- package/dist/templates/claude/settings.json +84 -0
- package/dist/templates/codebuddy/agents/trellis-check.md +115 -0
- package/dist/templates/codebuddy/agents/trellis-implement.md +110 -0
- package/dist/templates/codebuddy/agents/trellis-research.md +137 -0
- package/dist/templates/codebuddy/index.d.ts +15 -0
- package/dist/templates/codebuddy/index.d.ts.map +1 -0
- package/dist/templates/codebuddy/index.js +15 -0
- package/dist/templates/codebuddy/index.js.map +1 -0
- package/dist/templates/codebuddy/settings.json +70 -0
- package/dist/templates/codex/agents/trellis-check.toml +67 -0
- package/dist/templates/codex/agents/trellis-implement.toml +40 -0
- package/dist/templates/codex/agents/trellis-research.toml +73 -0
- package/dist/templates/codex/config.toml +28 -0
- package/dist/templates/codex/hooks/session-start.py +575 -0
- package/dist/templates/codex/hooks.json +15 -0
- package/dist/templates/codex/index.d.ts +39 -0
- package/dist/templates/codex/index.d.ts.map +1 -0
- package/dist/templates/codex/index.js +85 -0
- package/dist/templates/codex/index.js.map +1 -0
- package/dist/templates/codex/skills/before-dev/SKILL.md +40 -0
- package/dist/templates/codex/skills/brainstorm/SKILL.md +112 -0
- package/dist/templates/codex/skills/break-loop/SKILL.md +130 -0
- package/dist/templates/codex/skills/check/SKILL.md +98 -0
- package/dist/templates/codex/skills/check-cross-layer/SKILL.md +158 -0
- package/dist/templates/codex/skills/create-command/SKILL.md +101 -0
- package/dist/templates/codex/skills/finish-work/SKILL.md +90 -0
- package/dist/templates/codex/skills/improve-ut/SKILL.md +69 -0
- package/dist/templates/codex/skills/integrate-skill/SKILL.md +221 -0
- package/dist/templates/codex/skills/onboard/SKILL.md +363 -0
- package/dist/templates/codex/skills/record-session/SKILL.md +67 -0
- package/dist/templates/codex/skills/start/SKILL.md +66 -0
- package/dist/templates/codex/skills/update-spec/SKILL.md +335 -0
- package/dist/templates/common/bundled-skills/smart-search-cli/SKILL.md +388 -0
- package/dist/templates/common/bundled-skills/smart-search-cli/agents/openai.yaml +3 -0
- package/dist/templates/common/bundled-skills/smart-search-cli/examples/batch-search.md +98 -0
- package/dist/templates/common/bundled-skills/smart-search-cli/examples/evidence-gathering.md +89 -0
- package/dist/templates/common/bundled-skills/smart-search-cli/references/cli-contract.md +337 -0
- package/dist/templates/common/bundled-skills/trellis-cursor2plus-setup/SKILL.md +62 -0
- package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +73 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/add-project-local-conventions.md +83 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-agents.md +54 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-context-loading.md +84 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-hooks.md +57 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-skills-or-commands.md +80 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-spec-structure.md +83 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-task-lifecycle.md +88 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-workflow.md +65 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/overview.md +55 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/context-injection.md +68 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/generated-files.md +80 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/overview.md +51 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/spec-system.md +102 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/task-system.md +133 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/workflow.md +75 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/workspace-memory.md +71 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/agents.md +80 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/hooks-and-settings.md +69 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/overview.md +61 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/platform-map.md +76 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/skills-and-commands.md +128 -0
- package/dist/templates/common/bundled-skills/trellis-micro-grill/SKILL.md +47 -0
- package/dist/templates/common/bundled-skills/trellis-skill-creator/SKILL.md +44 -0
- package/dist/templates/common/bundled-skills/trellis-skill-creator/references/authoring-rules.md +97 -0
- package/dist/templates/common/bundled-skills/trellis-skill-creator/references/review-checklist.md +47 -0
- package/dist/templates/common/bundled-skills/trellis-skill-creator/references/trellis-skill-locations.md +52 -0
- package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/SKILL.md +41 -0
- package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/mcp-setup.md +90 -0
- package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/repository-analysis.md +59 -0
- package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/spec-task-planning.md +61 -0
- package/dist/templates/common/bundled-skills/trellis-spec-bootstrap/references/spec-writing.md +70 -0
- package/dist/templates/common/commands/continue.md +63 -0
- package/dist/templates/common/commands/finish-work.md +68 -0
- package/dist/templates/common/commands/start.md +69 -0
- package/dist/templates/common/index.d.ts +48 -0
- package/dist/templates/common/index.d.ts.map +1 -0
- package/dist/templates/common/index.js +104 -0
- package/dist/templates/common/index.js.map +1 -0
- package/dist/templates/common/skills/before-dev.md +35 -0
- package/dist/templates/common/skills/brainstorm.md +164 -0
- package/dist/templates/common/skills/break-loop.md +125 -0
- package/dist/templates/common/skills/check.md +106 -0
- package/dist/templates/common/skills/update-spec.md +364 -0
- package/dist/templates/copilot/hooks/session-start.py +557 -0
- package/dist/templates/copilot/hooks.json +19 -0
- package/dist/templates/copilot/index.d.ts +23 -0
- package/dist/templates/copilot/index.d.ts.map +1 -0
- package/dist/templates/copilot/index.js +54 -0
- package/dist/templates/copilot/index.js.map +1 -0
- package/dist/templates/copilot/prompts/before-dev.prompt.md +39 -0
- package/dist/templates/copilot/prompts/brainstorm.prompt.md +111 -0
- package/dist/templates/copilot/prompts/break-loop.prompt.md +129 -0
- package/dist/templates/copilot/prompts/check-cross-layer.prompt.md +157 -0
- package/dist/templates/copilot/prompts/check.prompt.md +97 -0
- package/dist/templates/copilot/prompts/create-command.prompt.md +116 -0
- package/dist/templates/copilot/prompts/finish-work.prompt.md +99 -0
- package/dist/templates/copilot/prompts/integrate-skill.prompt.md +223 -0
- package/dist/templates/copilot/prompts/onboard.prompt.md +362 -0
- package/dist/templates/copilot/prompts/parallel.prompt.md +204 -0
- package/dist/templates/copilot/prompts/record-session.prompt.md +66 -0
- package/dist/templates/copilot/prompts/start.prompt.md +63 -0
- package/dist/templates/copilot/prompts/update-spec.prompt.md +358 -0
- package/dist/templates/cursor/agents/trellis-check.md +139 -0
- package/dist/templates/cursor/agents/trellis-implement.md +119 -0
- package/dist/templates/cursor/agents/trellis-research.md +147 -0
- package/dist/templates/cursor/commands/cursor2plus-setup.md +82 -0
- package/dist/templates/cursor/hooks.json +30 -0
- package/dist/templates/cursor/index.d.ts +19 -0
- package/dist/templates/cursor/index.d.ts.map +1 -0
- package/dist/templates/cursor/index.js +19 -0
- package/dist/templates/cursor/index.js.map +1 -0
- package/dist/templates/cursor/rules/trellis-triage.mdc +38 -0
- package/dist/templates/cursor/worktrees.json +11 -0
- package/dist/templates/droid/droids/trellis-check.md +107 -0
- package/dist/templates/droid/droids/trellis-implement.md +102 -0
- package/dist/templates/droid/droids/trellis-research.md +137 -0
- package/dist/templates/droid/index.d.ts +15 -0
- package/dist/templates/droid/index.d.ts.map +1 -0
- package/dist/templates/droid/index.js +15 -0
- package/dist/templates/droid/index.js.map +1 -0
- package/dist/templates/droid/settings.json +70 -0
- package/dist/templates/extract.d.ts +40 -0
- package/dist/templates/extract.d.ts.map +1 -0
- package/dist/templates/extract.js +106 -0
- package/dist/templates/extract.js.map +1 -0
- package/dist/templates/gemini/agents/trellis-check.md +107 -0
- package/dist/templates/gemini/agents/trellis-implement.md +102 -0
- package/dist/templates/gemini/agents/trellis-research.md +136 -0
- package/dist/templates/gemini/index.d.ts +13 -0
- package/dist/templates/gemini/index.d.ts.map +1 -0
- package/dist/templates/gemini/index.js +13 -0
- package/dist/templates/gemini/index.js.map +1 -0
- package/dist/templates/gemini/settings.json +28 -0
- package/dist/templates/kiro/agents/trellis-check.json +26 -0
- package/dist/templates/kiro/agents/trellis-implement.json +26 -0
- package/dist/templates/kiro/agents/trellis-research.json +30 -0
- package/dist/templates/kiro/index.d.ts +18 -0
- package/dist/templates/kiro/index.d.ts.map +1 -0
- package/dist/templates/kiro/index.js +18 -0
- package/dist/templates/kiro/index.js.map +1 -0
- package/dist/templates/markdown/agents.md +32 -0
- package/dist/templates/markdown/gitignore.txt +15 -0
- package/dist/templates/markdown/index.d.ts +30 -0
- package/dist/templates/markdown/index.d.ts.map +1 -0
- package/dist/templates/markdown/index.js +55 -0
- package/dist/templates/markdown/index.js.map +1 -0
- package/dist/templates/markdown/spec/backend/database-guidelines.md.txt +51 -0
- package/dist/templates/markdown/spec/backend/directory-structure.md.txt +54 -0
- package/dist/templates/markdown/spec/backend/error-handling.md.txt +51 -0
- package/dist/templates/markdown/spec/backend/index.md.txt +38 -0
- package/dist/templates/markdown/spec/backend/logging-guidelines.md.txt +51 -0
- package/dist/templates/markdown/spec/backend/quality-guidelines.md.txt +51 -0
- package/dist/templates/markdown/spec/frontend/component-guidelines.md.txt +59 -0
- package/dist/templates/markdown/spec/frontend/directory-structure.md.txt +54 -0
- package/dist/templates/markdown/spec/frontend/hook-guidelines.md.txt +51 -0
- package/dist/templates/markdown/spec/frontend/index.md.txt +39 -0
- package/dist/templates/markdown/spec/frontend/quality-guidelines.md.txt +51 -0
- package/dist/templates/markdown/spec/frontend/state-management.md.txt +51 -0
- package/dist/templates/markdown/spec/frontend/type-safety.md.txt +51 -0
- package/dist/templates/markdown/spec/guides/code-reuse-thinking-guide.md.txt +223 -0
- package/dist/templates/markdown/spec/guides/cross-layer-thinking-guide.md.txt +259 -0
- package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +633 -0
- package/dist/templates/markdown/spec/guides/cursor-context-injection-guide.md.txt +65 -0
- package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +382 -0
- package/dist/templates/markdown/spec/guides/durable-learning-decision-guide.md.txt +76 -0
- package/dist/templates/markdown/spec/guides/index.md.txt +101 -0
- package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +92 -0
- package/dist/templates/markdown/workspace-index.md +125 -0
- package/dist/templates/markdown/worktree.yaml.txt +58 -0
- package/dist/templates/opencode/agents/trellis-check.md +122 -0
- package/dist/templates/opencode/agents/trellis-implement.md +118 -0
- package/dist/templates/opencode/agents/trellis-research.md +145 -0
- package/dist/templates/opencode/lib/session-utils.js +667 -0
- package/dist/templates/opencode/lib/trellis-context.js +336 -0
- package/dist/templates/opencode/package.json +5 -0
- package/dist/templates/opencode/plugins/inject-subagent-context.js +495 -0
- package/dist/templates/opencode/plugins/inject-workflow-state.js +159 -0
- package/dist/templates/opencode/plugins/session-start.js +101 -0
- package/dist/templates/pi/agents/trellis-check.md +37 -0
- package/dist/templates/pi/agents/trellis-implement.md +42 -0
- package/dist/templates/pi/agents/trellis-research.md +25 -0
- package/dist/templates/pi/extensions/trellis/index.ts.txt +1600 -0
- package/dist/templates/pi/index.d.ts +5 -0
- package/dist/templates/pi/index.d.ts.map +1 -0
- package/dist/templates/pi/index.js +12 -0
- package/dist/templates/pi/index.js.map +1 -0
- package/dist/templates/pi/settings.json +12 -0
- package/dist/templates/qoder/agents/trellis-check.md +108 -0
- package/dist/templates/qoder/agents/trellis-implement.md +103 -0
- package/dist/templates/qoder/agents/trellis-research.md +137 -0
- package/dist/templates/qoder/index.d.ts +15 -0
- package/dist/templates/qoder/index.d.ts.map +1 -0
- package/dist/templates/qoder/index.js +15 -0
- package/dist/templates/qoder/index.js.map +1 -0
- package/dist/templates/qoder/settings.json +47 -0
- package/dist/templates/shared-hooks/index.d.ts +50 -0
- package/dist/templates/shared-hooks/index.d.ts.map +1 -0
- package/dist/templates/shared-hooks/index.js +92 -0
- package/dist/templates/shared-hooks/index.js.map +1 -0
- package/dist/templates/shared-hooks/inject-shell-session-context.py +183 -0
- package/dist/templates/shared-hooks/inject-subagent-context.py +774 -0
- package/dist/templates/shared-hooks/inject-workflow-state.py +366 -0
- package/dist/templates/shared-hooks/research-end-retrieval-pack.py +220 -0
- package/dist/templates/shared-hooks/session-start.py +873 -0
- package/dist/templates/template-utils.d.ts +28 -0
- package/dist/templates/template-utils.d.ts.map +1 -0
- package/dist/templates/template-utils.js +83 -0
- package/dist/templates/template-utils.js.map +1 -0
- package/dist/templates/trellis/config.yaml +132 -0
- package/dist/templates/trellis/gitignore.txt +39 -0
- package/dist/templates/trellis/index.d.ts +80 -0
- package/dist/templates/trellis/index.d.ts.map +1 -0
- package/dist/templates/trellis/index.js +171 -0
- package/dist/templates/trellis/index.js.map +1 -0
- package/dist/templates/trellis/local/README.md +59 -0
- package/dist/templates/trellis/local/config.local.json.example +6 -0
- package/dist/templates/trellis/local/index.d.ts +7 -0
- package/dist/templates/trellis/local/index.d.ts.map +1 -0
- package/dist/templates/trellis/local/index.js +14 -0
- package/dist/templates/trellis/local/index.js.map +1 -0
- package/dist/templates/trellis/local/patch_wpelc8.py +297 -0
- package/dist/templates/trellis/local/subagent-models.json.example +4 -0
- package/dist/templates/trellis/local/trellis-task-models.json.example +10 -0
- package/dist/templates/trellis/local/trellis-task-models.json5.example +45 -0
- package/dist/templates/trellis/local/trellis_task_models_config.py +200 -0
- package/dist/templates/trellis/scripts/__init__.py +5 -0
- package/dist/templates/trellis/scripts/add_session.py +547 -0
- package/dist/templates/trellis/scripts/build_context_pack.py +12 -0
- package/dist/templates/trellis/scripts/build_retrieval_pack.py +12 -0
- package/dist/templates/trellis/scripts/common/__init__.py +103 -0
- package/dist/templates/trellis/scripts/common/active_task.py +607 -0
- package/dist/templates/trellis/scripts/common/artifact_search.py +587 -0
- package/dist/templates/trellis/scripts/common/cli_adapter.py +811 -0
- package/dist/templates/trellis/scripts/common/cli_environment.py +155 -0
- package/dist/templates/trellis/scripts/common/codebase_retrieval_router.py +902 -0
- package/dist/templates/trellis/scripts/common/config.py +458 -0
- package/dist/templates/trellis/scripts/common/context_pack.py +371 -0
- package/dist/templates/trellis/scripts/common/developer.py +190 -0
- package/dist/templates/trellis/scripts/common/git.py +31 -0
- package/dist/templates/trellis/scripts/common/git_context.py +149 -0
- package/dist/templates/trellis/scripts/common/io.py +37 -0
- package/dist/templates/trellis/scripts/common/log.py +45 -0
- package/dist/templates/trellis/scripts/common/packages_context.py +239 -0
- package/dist/templates/trellis/scripts/common/parent_orchestration.py +691 -0
- package/dist/templates/trellis/scripts/common/paths.py +457 -0
- package/dist/templates/trellis/scripts/common/retrieval_adapter_metadata.py +889 -0
- package/dist/templates/trellis/scripts/common/retrieval_evidence.py +917 -0
- package/dist/templates/trellis/scripts/common/retrieval_pack.py +425 -0
- package/dist/templates/trellis/scripts/common/retrieval_pack_context.py +126 -0
- package/dist/templates/trellis/scripts/common/safe_commit.py +285 -0
- package/dist/templates/trellis/scripts/common/session_context.py +1159 -0
- package/dist/templates/trellis/scripts/common/session_memory.py +360 -0
- package/dist/templates/trellis/scripts/common/smart_search_evidence.py +479 -0
- package/dist/templates/trellis/scripts/common/smart_search_resolve.py +125 -0
- package/dist/templates/trellis/scripts/common/task_context.py +223 -0
- package/dist/templates/trellis/scripts/common/task_dashboard.py +140 -0
- package/dist/templates/trellis/scripts/common/task_gates.py +1735 -0
- package/dist/templates/trellis/scripts/common/task_map.py +586 -0
- package/dist/templates/trellis/scripts/common/task_queue.py +188 -0
- package/dist/templates/trellis/scripts/common/task_store.py +1533 -0
- package/dist/templates/trellis/scripts/common/task_utils.py +274 -0
- package/dist/templates/trellis/scripts/common/tasks.py +231 -0
- package/dist/templates/trellis/scripts/common/test_retrieval_arbitration.py +333 -0
- package/dist/templates/trellis/scripts/common/trellis_config.py +131 -0
- package/dist/templates/trellis/scripts/common/types.py +110 -0
- package/dist/templates/trellis/scripts/common/workflow_phase.py +212 -0
- package/dist/templates/trellis/scripts/get_context.py +17 -0
- package/dist/templates/trellis/scripts/get_developer.py +26 -0
- package/dist/templates/trellis/scripts/hooks/linear_sync.py +243 -0
- package/dist/templates/trellis/scripts/init_developer.py +51 -0
- package/dist/templates/trellis/scripts/route_codebase_retrieval.py +77 -0
- package/dist/templates/trellis/scripts/run_smart_search.py +12 -0
- package/dist/templates/trellis/scripts/search_artifacts.py +13 -0
- package/dist/templates/trellis/scripts/search_memory.py +12 -0
- package/dist/templates/trellis/scripts/task.py +851 -0
- package/dist/templates/trellis/tasks/.gitkeep +0 -0
- package/dist/templates/trellis/tasks/templates/release-execution/design.md +61 -0
- package/dist/templates/trellis/tasks/templates/release-execution/handoff-template.md +29 -0
- package/dist/templates/trellis/tasks/templates/release-execution/implement.md +29 -0
- package/dist/templates/trellis/tasks/templates/release-execution/prd.md +31 -0
- package/dist/templates/trellis/tasks/templates/release-readiness/design.md +50 -0
- package/dist/templates/trellis/tasks/templates/release-readiness/handoff-template.md +39 -0
- package/dist/templates/trellis/tasks/templates/release-readiness/implement.md +29 -0
- package/dist/templates/trellis/tasks/templates/release-readiness/prd.md +31 -0
- package/dist/templates/trellis/workflow.md +960 -0
- package/dist/types/ai-tools.d.ts +106 -0
- package/dist/types/ai-tools.d.ts.map +1 -0
- package/dist/types/ai-tools.js +294 -0
- package/dist/types/ai-tools.js.map +1 -0
- package/dist/types/migration.d.ts +125 -0
- package/dist/types/migration.d.ts.map +1 -0
- package/dist/types/migration.js +8 -0
- package/dist/types/migration.js.map +1 -0
- package/dist/utils/codebase-retrieval-router.d.ts +77 -0
- package/dist/utils/codebase-retrieval-router.d.ts.map +1 -0
- package/dist/utils/codebase-retrieval-router.js +767 -0
- package/dist/utils/codebase-retrieval-router.js.map +1 -0
- package/dist/utils/compare-versions.d.ts +12 -0
- package/dist/utils/compare-versions.d.ts.map +1 -0
- package/dist/utils/compare-versions.js +86 -0
- package/dist/utils/compare-versions.js.map +1 -0
- package/dist/utils/cwd-guard.d.ts +38 -0
- package/dist/utils/cwd-guard.d.ts.map +1 -0
- package/dist/utils/cwd-guard.js +62 -0
- package/dist/utils/cwd-guard.js.map +1 -0
- package/dist/utils/file-writer.d.ts +36 -0
- package/dist/utils/file-writer.d.ts.map +1 -0
- package/dist/utils/file-writer.js +203 -0
- package/dist/utils/file-writer.js.map +1 -0
- package/dist/utils/manifest-prune.d.ts +61 -0
- package/dist/utils/manifest-prune.d.ts.map +1 -0
- package/dist/utils/manifest-prune.js +136 -0
- package/dist/utils/manifest-prune.js.map +1 -0
- package/dist/utils/posix.d.ts +13 -0
- package/dist/utils/posix.d.ts.map +1 -0
- package/dist/utils/posix.js +15 -0
- package/dist/utils/posix.js.map +1 -0
- package/dist/utils/post-update-smoke.d.ts +7 -0
- package/dist/utils/post-update-smoke.d.ts.map +1 -0
- package/dist/utils/post-update-smoke.js +40 -0
- package/dist/utils/post-update-smoke.js.map +1 -0
- package/dist/utils/project-capabilities.d.ts +54 -0
- package/dist/utils/project-capabilities.d.ts.map +1 -0
- package/dist/utils/project-capabilities.js +515 -0
- package/dist/utils/project-capabilities.js.map +1 -0
- package/dist/utils/project-detector.d.ts +46 -0
- package/dist/utils/project-detector.d.ts.map +1 -0
- package/dist/utils/project-detector.js +666 -0
- package/dist/utils/project-detector.js.map +1 -0
- package/dist/utils/proxy.d.ts +25 -0
- package/dist/utils/proxy.d.ts.map +1 -0
- package/dist/utils/proxy.js +60 -0
- package/dist/utils/proxy.js.map +1 -0
- package/dist/utils/readiness.d.ts +20 -0
- package/dist/utils/readiness.d.ts.map +1 -0
- package/dist/utils/readiness.js +430 -0
- package/dist/utils/readiness.js.map +1 -0
- package/dist/utils/retrieval-execution-telemetry.d.ts +162 -0
- package/dist/utils/retrieval-execution-telemetry.d.ts.map +1 -0
- package/dist/utils/retrieval-execution-telemetry.js +288 -0
- package/dist/utils/retrieval-execution-telemetry.js.map +1 -0
- package/dist/utils/retrieval-result-ranking.d.ts +40 -0
- package/dist/utils/retrieval-result-ranking.d.ts.map +1 -0
- package/dist/utils/retrieval-result-ranking.js +162 -0
- package/dist/utils/retrieval-result-ranking.js.map +1 -0
- package/dist/utils/task-json.d.ts +13 -0
- package/dist/utils/task-json.d.ts.map +1 -0
- package/dist/utils/task-json.js +12 -0
- package/dist/utils/task-json.js.map +1 -0
- package/dist/utils/template-fetcher.d.ts +150 -0
- package/dist/utils/template-fetcher.d.ts.map +1 -0
- package/dist/utils/template-fetcher.js +907 -0
- package/dist/utils/template-fetcher.js.map +1 -0
- package/dist/utils/template-hash.d.ts +123 -0
- package/dist/utils/template-hash.d.ts.map +1 -0
- package/dist/utils/template-hash.js +334 -0
- package/dist/utils/template-hash.js.map +1 -0
- package/dist/utils/uninstall-scrubbers.d.ts +66 -0
- package/dist/utils/uninstall-scrubbers.d.ts.map +1 -0
- package/dist/utils/uninstall-scrubbers.js +342 -0
- package/dist/utils/uninstall-scrubbers.js.map +1 -0
- package/dist/utils/update-rollout-report.d.ts +137 -0
- package/dist/utils/update-rollout-report.d.ts.map +1 -0
- package/dist/utils/update-rollout-report.js +108 -0
- package/dist/utils/update-rollout-report.js.map +1 -0
- package/dist/utils/workflow-resolver.d.ts +86 -0
- package/dist/utils/workflow-resolver.d.ts.map +1 -0
- package/dist/utils/workflow-resolver.js +265 -0
- package/dist/utils/workflow-resolver.js.map +1 -0
- package/package.json +137 -0
- package/scripts/postinstall.js +45 -0
- package/vendor/smart-search/LICENSE +21 -0
- package/vendor/smart-search/README.md +412 -0
- package/vendor/smart-search/README.zh-CN.md +430 -0
- package/vendor/smart-search/npm/bin/smart-search.js +63 -0
- package/vendor/smart-search/npm/scripts/postinstall.js +87 -0
- package/vendor/smart-search/npm/scripts/resolve-prerelease-version.js +108 -0
- package/vendor/smart-search/npm/scripts/set-package-version.js +35 -0
- package/vendor/smart-search/npm/scripts/sync-python-version.js +22 -0
- package/vendor/smart-search/npm/scripts/test-wrapper-repair.js +99 -0
- package/vendor/smart-search/npm/scripts/test.js +85 -0
- package/vendor/smart-search/package.json +42 -0
- package/vendor/smart-search/pyproject.toml +37 -0
- package/vendor/smart-search/skills/smart-search-cli/SKILL.md +387 -0
- package/vendor/smart-search/skills/smart-search-cli/agents/openai.yaml +3 -0
- package/vendor/smart-search/skills/smart-search-cli/examples/batch-search.md +98 -0
- package/vendor/smart-search/skills/smart-search-cli/examples/evidence-gathering.md +89 -0
- package/vendor/smart-search/skills/smart-search-cli/references/cli-contract.md +337 -0
- package/vendor/smart-search/src/smart_search/__init__.py +1 -0
- package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/SKILL.md +387 -0
- package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/agents/openai.yaml +3 -0
- package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/examples/batch-search.md +98 -0
- package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/examples/evidence-gathering.md +89 -0
- package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/references/cli-contract.md +337 -0
- package/vendor/smart-search/src/smart_search/cli.py +2003 -0
- package/vendor/smart-search/src/smart_search/config.py +580 -0
- package/vendor/smart-search/src/smart_search/logger.py +43 -0
- package/vendor/smart-search/src/smart_search/providers/__init__.py +16 -0
- package/vendor/smart-search/src/smart_search/providers/base.py +41 -0
- package/vendor/smart-search/src/smart_search/providers/context7.py +141 -0
- package/vendor/smart-search/src/smart_search/providers/exa.py +206 -0
- package/vendor/smart-search/src/smart_search/providers/jina.py +136 -0
- package/vendor/smart-search/src/smart_search/providers/openai_compatible.py +370 -0
- package/vendor/smart-search/src/smart_search/providers/zhipu.py +143 -0
- package/vendor/smart-search/src/smart_search/service.py +2865 -0
- package/vendor/smart-search/src/smart_search/sources.py +429 -0
- package/vendor/smart-search/src/smart_search/utils.py +220 -0
|
@@ -0,0 +1,2865 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .config import config
|
|
13
|
+
from .logger import log_info
|
|
14
|
+
from .providers.context7 import Context7Provider
|
|
15
|
+
from .providers.exa import ExaSearchProvider
|
|
16
|
+
from .providers.jina import JinaReaderProvider
|
|
17
|
+
from .providers.openai_compatible import OpenAICompatibleSearchProvider, get_local_time_info
|
|
18
|
+
from .providers.zhipu import ZhipuWebSearchProvider
|
|
19
|
+
from .sources import merge_sources, new_session_id, split_answer_and_sources
|
|
20
|
+
from .utils import search_prompt
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_AVAILABLE_MODELS_CACHE: dict[tuple[str, str], list[str]] = {}
|
|
24
|
+
_AVAILABLE_MODELS_LOCK = asyncio.Lock()
|
|
25
|
+
SOURCE_PROVENANCE_WARNING = (
|
|
26
|
+
"extra_sources are retrieved in parallel and are not automatically used to verify generated content; "
|
|
27
|
+
"use fetch on key URLs for claim-level evidence."
|
|
28
|
+
)
|
|
29
|
+
MINIMUM_PROFILE_ERROR = (
|
|
30
|
+
"最低配置不满足:必须至少配置 main_search、docs_search、web_fetch 三类能力各一个 provider。"
|
|
31
|
+
)
|
|
32
|
+
OPENAI_COMPATIBLE_DIAGNOSE_COMMAND = "smart-search diagnose openai-compatible --format markdown"
|
|
33
|
+
DOCS_INTENT_ASCII_KEYWORDS = {
|
|
34
|
+
"api",
|
|
35
|
+
"sdk",
|
|
36
|
+
"library",
|
|
37
|
+
"framework",
|
|
38
|
+
"docs",
|
|
39
|
+
"documentation",
|
|
40
|
+
"reference",
|
|
41
|
+
"guide",
|
|
42
|
+
"tutorial",
|
|
43
|
+
"quickstart",
|
|
44
|
+
"example",
|
|
45
|
+
"examples",
|
|
46
|
+
"usage",
|
|
47
|
+
"manual",
|
|
48
|
+
"changelog",
|
|
49
|
+
"release notes",
|
|
50
|
+
"migration",
|
|
51
|
+
"react",
|
|
52
|
+
"next.js",
|
|
53
|
+
"vue",
|
|
54
|
+
"python",
|
|
55
|
+
"prisma",
|
|
56
|
+
"langchain",
|
|
57
|
+
"openai",
|
|
58
|
+
"context7",
|
|
59
|
+
}
|
|
60
|
+
DOCS_INTENT_TEXT_KEYWORDS = {
|
|
61
|
+
"接口",
|
|
62
|
+
"文档",
|
|
63
|
+
"库",
|
|
64
|
+
"框架",
|
|
65
|
+
"函数",
|
|
66
|
+
"参数",
|
|
67
|
+
"配置",
|
|
68
|
+
"教程",
|
|
69
|
+
"指南",
|
|
70
|
+
"示例",
|
|
71
|
+
"用法",
|
|
72
|
+
"快速开始",
|
|
73
|
+
"迁移",
|
|
74
|
+
"变更日志",
|
|
75
|
+
"版本说明",
|
|
76
|
+
}
|
|
77
|
+
DOCS_INTENT_KEYWORDS = DOCS_INTENT_ASCII_KEYWORDS | DOCS_INTENT_TEXT_KEYWORDS
|
|
78
|
+
ZH_CURRENT_KEYWORDS = {
|
|
79
|
+
"今天",
|
|
80
|
+
"最新",
|
|
81
|
+
"国内",
|
|
82
|
+
"中国",
|
|
83
|
+
"政策",
|
|
84
|
+
"新闻",
|
|
85
|
+
"实时",
|
|
86
|
+
"刚刚",
|
|
87
|
+
"本周",
|
|
88
|
+
"本月",
|
|
89
|
+
"战报",
|
|
90
|
+
"比分",
|
|
91
|
+
"赛程",
|
|
92
|
+
"赛果",
|
|
93
|
+
"季后赛",
|
|
94
|
+
"比赛",
|
|
95
|
+
"nba",
|
|
96
|
+
"足球",
|
|
97
|
+
"篮球",
|
|
98
|
+
}
|
|
99
|
+
FETCH_INTENT_KEYWORDS = {"http://", "https://"}
|
|
100
|
+
DEEP_ALLOWED_TOOLS = {
|
|
101
|
+
"search",
|
|
102
|
+
"exa-search",
|
|
103
|
+
"exa-similar",
|
|
104
|
+
"context7-library",
|
|
105
|
+
"context7-docs",
|
|
106
|
+
"fetch",
|
|
107
|
+
"map",
|
|
108
|
+
}
|
|
109
|
+
DEEP_TRIGGER_KEYWORDS = {
|
|
110
|
+
"深度搜索",
|
|
111
|
+
"深度调研",
|
|
112
|
+
"深入搜索",
|
|
113
|
+
"deep search",
|
|
114
|
+
"deep research",
|
|
115
|
+
"核验",
|
|
116
|
+
"验证",
|
|
117
|
+
"交叉验证",
|
|
118
|
+
"选型",
|
|
119
|
+
"对比",
|
|
120
|
+
"评测",
|
|
121
|
+
}
|
|
122
|
+
DEEP_HIGH_COMPLEXITY_KEYWORDS = {
|
|
123
|
+
"对比",
|
|
124
|
+
"选型",
|
|
125
|
+
"核验",
|
|
126
|
+
"验证",
|
|
127
|
+
"为什么",
|
|
128
|
+
"架构",
|
|
129
|
+
"方案",
|
|
130
|
+
"趋势",
|
|
131
|
+
"优缺点",
|
|
132
|
+
"风险",
|
|
133
|
+
"区别",
|
|
134
|
+
"怎么选",
|
|
135
|
+
"compare",
|
|
136
|
+
"comparison",
|
|
137
|
+
"evaluate",
|
|
138
|
+
"architecture",
|
|
139
|
+
"tradeoff",
|
|
140
|
+
"trade-off",
|
|
141
|
+
"risk",
|
|
142
|
+
}
|
|
143
|
+
DEEP_RECENT_KEYWORDS = {
|
|
144
|
+
"最近",
|
|
145
|
+
"最新",
|
|
146
|
+
"当前",
|
|
147
|
+
"现在",
|
|
148
|
+
"今天",
|
|
149
|
+
"实时",
|
|
150
|
+
"刚刚",
|
|
151
|
+
"本周",
|
|
152
|
+
"本月",
|
|
153
|
+
"recent",
|
|
154
|
+
"latest",
|
|
155
|
+
"current",
|
|
156
|
+
"today",
|
|
157
|
+
}
|
|
158
|
+
DEEP_CURRENT_KEYWORDS = {"今天", "实时", "刚刚", "当前", "现在", "today", "current", "live", "realtime"}
|
|
159
|
+
DEEP_CHINA_KEYWORDS = {"中国", "国内", "中文", "政策", "监管", "公告", "A股", "港股"}
|
|
160
|
+
DEEP_EXA_DISCOVERY_KEYWORDS = {
|
|
161
|
+
"官方",
|
|
162
|
+
"官网",
|
|
163
|
+
"论文",
|
|
164
|
+
"paper",
|
|
165
|
+
"papers",
|
|
166
|
+
"research paper",
|
|
167
|
+
"产品页",
|
|
168
|
+
"product page",
|
|
169
|
+
"可信站点",
|
|
170
|
+
"trusted",
|
|
171
|
+
"known domain",
|
|
172
|
+
"known domains",
|
|
173
|
+
"site:",
|
|
174
|
+
"白皮书",
|
|
175
|
+
"standard",
|
|
176
|
+
"standards",
|
|
177
|
+
}
|
|
178
|
+
RESEARCH_ROUTE_POLICY_VERSION = "research-router-v2-bilingual-no-zhipu"
|
|
179
|
+
RESEARCH_JS_HEAVY_KEYWORDS = {
|
|
180
|
+
"js-heavy",
|
|
181
|
+
"javascript",
|
|
182
|
+
"dynamic",
|
|
183
|
+
"动态页面",
|
|
184
|
+
"浏览器渲染",
|
|
185
|
+
"登录页",
|
|
186
|
+
"cloudflare",
|
|
187
|
+
"screenshot",
|
|
188
|
+
"ocr",
|
|
189
|
+
"扫描",
|
|
190
|
+
}
|
|
191
|
+
RESEARCH_PDF_KEYWORDS = {"pdf", "arxiv", "论文", "paper", ".pdf"}
|
|
192
|
+
RESEARCH_PROFILE_ORDER = {
|
|
193
|
+
"main_search": ["openai-compatible"],
|
|
194
|
+
"web_search": ["tavily", "firecrawl"],
|
|
195
|
+
"docs_search": ["context7", "exa"],
|
|
196
|
+
"web_fetch": ["tavily", "jina", "firecrawl"],
|
|
197
|
+
"site_map": ["tavily"],
|
|
198
|
+
"synthesis": ["main-search"],
|
|
199
|
+
}
|
|
200
|
+
PROVIDER_PROFILES: dict[str, dict[str, Any]] = {
|
|
201
|
+
"openai-compatible": {
|
|
202
|
+
"capability": "main_search",
|
|
203
|
+
"strengths": ["broad synthesis", "relay compatibility"],
|
|
204
|
+
"exclusions": ["xAI server tools"],
|
|
205
|
+
"fallback_group": "main_search",
|
|
206
|
+
"minimum_profile_role": "main_search",
|
|
207
|
+
"quality_filters": ["source extraction required for high-risk claims"],
|
|
208
|
+
"route_reasons": ["relay-compatible primary synthesis"],
|
|
209
|
+
},
|
|
210
|
+
"context7": {
|
|
211
|
+
"capability": "docs_search",
|
|
212
|
+
"strengths": ["library docs", "API docs", "framework docs", "versioned snippets"],
|
|
213
|
+
"exclusions": ["general news", "generic web facts"],
|
|
214
|
+
"fallback_group": "docs_search",
|
|
215
|
+
"minimum_profile_role": "docs_search",
|
|
216
|
+
"quality_filters": ["library id required", "content required before citation"],
|
|
217
|
+
"route_reasons": ["docs/API evidence", "framework reference"],
|
|
218
|
+
},
|
|
219
|
+
"exa": {
|
|
220
|
+
"capability": "docs_search",
|
|
221
|
+
"strengths": ["official domains", "papers", "product pages", "trusted low-noise discovery", "similar pages"],
|
|
222
|
+
"exclusions": ["default second hop for every high-risk claim"],
|
|
223
|
+
"fallback_group": "docs_search",
|
|
224
|
+
"minimum_profile_role": "docs_search",
|
|
225
|
+
"quality_filters": ["URL required", "fetch before proof citation"],
|
|
226
|
+
"route_reasons": ["official low-noise discovery", "paper/product discovery"],
|
|
227
|
+
},
|
|
228
|
+
"zhipu": {
|
|
229
|
+
"capability": "web_search",
|
|
230
|
+
"deprecated": True,
|
|
231
|
+
"strengths": ["legacy Chinese/current web-search command compatibility"],
|
|
232
|
+
"exclusions": ["web_fetch", "chat model selection"],
|
|
233
|
+
"fallback_group": "web_search",
|
|
234
|
+
"minimum_profile_role": "",
|
|
235
|
+
"quality_filters": ["URL required", "fetch before proof citation"],
|
|
236
|
+
"route_reasons": ["deprecated; not used by default routing"],
|
|
237
|
+
},
|
|
238
|
+
"tavily": {
|
|
239
|
+
"capability": "web_search",
|
|
240
|
+
"capabilities": ["web_search", "web_fetch", "site_map"],
|
|
241
|
+
"strengths": ["broad source discovery", "site map", "URL extract"],
|
|
242
|
+
"exclusions": ["docs semantic replacement"],
|
|
243
|
+
"fallback_group": "web_search/web_fetch/site_map",
|
|
244
|
+
"minimum_profile_role": "web_fetch",
|
|
245
|
+
"quality_filters": ["non-empty normalized result", "non-empty extracted content"],
|
|
246
|
+
"route_reasons": ["broad source discovery", "site map", "URL fetch"],
|
|
247
|
+
},
|
|
248
|
+
"jina": {
|
|
249
|
+
"capability": "web_fetch",
|
|
250
|
+
"strengths": ["known public URL", "PDF", "arXiv", "clean markdown", "ReaderLM-v2 with key"],
|
|
251
|
+
"exclusions": ["general search provider", "anonymous standard minimum profile"],
|
|
252
|
+
"fallback_group": "web_fetch",
|
|
253
|
+
"minimum_profile_role": "web_fetch_with_key",
|
|
254
|
+
"quality_filters": ["non-empty markdown", "challenge page rejection", "ReaderLM-v2 requires key"],
|
|
255
|
+
"route_reasons": ["known URL extraction", "PDF/arXiv extraction"],
|
|
256
|
+
},
|
|
257
|
+
"firecrawl": {
|
|
258
|
+
"capability": "web_fetch",
|
|
259
|
+
"capabilities": ["web_search", "web_fetch"],
|
|
260
|
+
"strengths": ["robust scrape fallback", "JS-heavy pages", "dynamic pages", "OCR/PDF/structured extraction"],
|
|
261
|
+
"exclusions": ["docs semantic replacement"],
|
|
262
|
+
"fallback_group": "web_search/web_fetch",
|
|
263
|
+
"minimum_profile_role": "web_fetch",
|
|
264
|
+
"quality_filters": ["non-empty normalized result", "non-empty extracted content"],
|
|
265
|
+
"route_reasons": ["JS-heavy fetch", "dynamic/browser-like extraction", "robust fetch fallback"],
|
|
266
|
+
},
|
|
267
|
+
"main-search": {
|
|
268
|
+
"capability": "synthesis",
|
|
269
|
+
"strengths": ["evidence-only final synthesis"],
|
|
270
|
+
"exclusions": ["live source discovery during research synthesis"],
|
|
271
|
+
"fallback_group": "synthesis",
|
|
272
|
+
"minimum_profile_role": "",
|
|
273
|
+
"quality_filters": ["fetched evidence only", "no provider calls during synthesis"],
|
|
274
|
+
"route_reasons": ["evidence-only synthesis"],
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
MAIN_SEARCH_FALLBACK_CHAIN = ["openai-compatible"]
|
|
278
|
+
MAIN_SEARCH_PROVIDER_ALIASES = {
|
|
279
|
+
"openai-compatible": {"openai-compatible", "openai", "chat-completions", "primary"},
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _elapsed_ms(start: float) -> float:
|
|
284
|
+
return round((time.time() - start) * 1000, 2)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _normalize_domain_filter(value: str | list[str] | tuple[str, ...] | None) -> list[str] | None:
|
|
288
|
+
if not value:
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
raw_parts = [value] if isinstance(value, str) else [str(item) for item in value if item]
|
|
292
|
+
domains: list[str] = []
|
|
293
|
+
for part in raw_parts:
|
|
294
|
+
domains.extend(item.strip() for item in re.split(r"[\s,]+", part) if item.strip())
|
|
295
|
+
return domains or None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _empty_search_result(
|
|
299
|
+
start: float,
|
|
300
|
+
session_id: str,
|
|
301
|
+
query: str,
|
|
302
|
+
error_type: str,
|
|
303
|
+
error: str,
|
|
304
|
+
primary_api_mode: str = "",
|
|
305
|
+
extra: dict[str, Any] | None = None,
|
|
306
|
+
) -> dict[str, Any]:
|
|
307
|
+
data: dict[str, Any] = {
|
|
308
|
+
"ok": False,
|
|
309
|
+
"error_type": error_type,
|
|
310
|
+
"error": error,
|
|
311
|
+
"session_id": session_id,
|
|
312
|
+
"query": query,
|
|
313
|
+
"primary_api_mode": primary_api_mode,
|
|
314
|
+
"content": "",
|
|
315
|
+
"sources": [],
|
|
316
|
+
"sources_count": 0,
|
|
317
|
+
"primary_sources": [],
|
|
318
|
+
"primary_sources_count": 0,
|
|
319
|
+
"extra_sources": [],
|
|
320
|
+
"extra_sources_count": 0,
|
|
321
|
+
"source_warning": "",
|
|
322
|
+
"routing_decision": {},
|
|
323
|
+
"providers_used": [],
|
|
324
|
+
"provider_attempts": [],
|
|
325
|
+
"fallback_used": False,
|
|
326
|
+
"validation_level": "",
|
|
327
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
328
|
+
}
|
|
329
|
+
if extra:
|
|
330
|
+
data.update(extra)
|
|
331
|
+
return data
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _attempt(
|
|
335
|
+
capability: str,
|
|
336
|
+
provider: str,
|
|
337
|
+
status: str,
|
|
338
|
+
start: float,
|
|
339
|
+
result_count: int = 0,
|
|
340
|
+
error_type: str = "",
|
|
341
|
+
error: str = "",
|
|
342
|
+
) -> dict[str, Any]:
|
|
343
|
+
return {
|
|
344
|
+
"capability": capability,
|
|
345
|
+
"provider": provider,
|
|
346
|
+
"status": status,
|
|
347
|
+
"error_type": error_type,
|
|
348
|
+
"error": error,
|
|
349
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
350
|
+
"result_count": result_count,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _normalize_source_results(results: list[dict] | None, provider: str) -> list[dict]:
|
|
355
|
+
normalized: list[dict] = []
|
|
356
|
+
for item in results or []:
|
|
357
|
+
url = (item.get("url") or item.get("link") or "").strip()
|
|
358
|
+
if not url:
|
|
359
|
+
continue
|
|
360
|
+
out = {"url": url, "provider": item.get("provider") or provider}
|
|
361
|
+
title = (item.get("title") or "").strip()
|
|
362
|
+
if title:
|
|
363
|
+
out["title"] = title
|
|
364
|
+
desc = (item.get("description") or item.get("content") or item.get("snippet") or "").strip()
|
|
365
|
+
if desc:
|
|
366
|
+
out["description"] = desc
|
|
367
|
+
published = item.get("published_date") or item.get("publishedDate") or item.get("publish_date")
|
|
368
|
+
if published:
|
|
369
|
+
out["published_date"] = published
|
|
370
|
+
source = item.get("source") or item.get("media")
|
|
371
|
+
if source:
|
|
372
|
+
out["source"] = source
|
|
373
|
+
normalized.append(out)
|
|
374
|
+
return normalized
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _provider_names_from_attempts(attempts: list[dict]) -> list[str]:
|
|
378
|
+
names: list[str] = []
|
|
379
|
+
for attempt in attempts:
|
|
380
|
+
provider = attempt.get("provider")
|
|
381
|
+
if attempt.get("status") == "ok" and provider and provider not in names:
|
|
382
|
+
names.append(provider)
|
|
383
|
+
return names
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _fallback_used(attempts: list[dict]) -> bool:
|
|
387
|
+
by_capability: dict[str, list[dict]] = {}
|
|
388
|
+
for attempt in attempts:
|
|
389
|
+
capability = attempt.get("capability", "")
|
|
390
|
+
if attempt.get("status") in {"ok", "empty", "error"}:
|
|
391
|
+
by_capability.setdefault(capability, []).append(attempt)
|
|
392
|
+
for capability_attempts in by_capability.values():
|
|
393
|
+
previous_failed = False
|
|
394
|
+
previous_provider = ""
|
|
395
|
+
for attempt in capability_attempts:
|
|
396
|
+
provider = attempt.get("provider", "")
|
|
397
|
+
status = attempt.get("status")
|
|
398
|
+
if previous_failed:
|
|
399
|
+
return True
|
|
400
|
+
if previous_provider and provider and provider != previous_provider:
|
|
401
|
+
return True
|
|
402
|
+
previous_failed = status in {"empty", "error"}
|
|
403
|
+
previous_provider = provider or previous_provider
|
|
404
|
+
return False
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def provider_profiles() -> dict[str, dict[str, Any]]:
|
|
408
|
+
return {provider: dict(profile) for provider, profile in PROVIDER_PROFILES.items()}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _provider_supports_capability(provider: str, capability: str) -> bool:
|
|
412
|
+
profile = PROVIDER_PROFILES.get(provider, {})
|
|
413
|
+
capabilities = set(profile.get("capabilities") or [profile.get("capability", "")])
|
|
414
|
+
return capability in capabilities
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _provider_configured(provider: str) -> bool:
|
|
418
|
+
if provider == "openai-compatible":
|
|
419
|
+
return bool(config.openai_compatible_api_url and config.openai_compatible_api_key)
|
|
420
|
+
if provider == "context7":
|
|
421
|
+
return bool(config.context7_api_key)
|
|
422
|
+
if provider == "exa":
|
|
423
|
+
return bool(config.exa_api_key)
|
|
424
|
+
if provider == "zhipu":
|
|
425
|
+
return bool(config.zhipu_api_key)
|
|
426
|
+
if provider == "tavily":
|
|
427
|
+
return bool(config.tavily_api_key)
|
|
428
|
+
if provider == "jina":
|
|
429
|
+
return bool(config.jina_api_key)
|
|
430
|
+
if provider == "firecrawl":
|
|
431
|
+
return bool(config.firecrawl_api_key)
|
|
432
|
+
if provider == "main-search":
|
|
433
|
+
return bool(config.openai_compatible_api_url and config.openai_compatible_api_key)
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _configured_for_capability(capability: str, capability_status: dict[str, Any] | None = None) -> list[str]:
|
|
438
|
+
if capability_status is not None:
|
|
439
|
+
configured = set(capability_status.get(capability, {}).get("configured") or [])
|
|
440
|
+
return [
|
|
441
|
+
provider
|
|
442
|
+
for provider in RESEARCH_PROFILE_ORDER.get(capability, [])
|
|
443
|
+
if provider in configured and _provider_supports_capability(provider, capability)
|
|
444
|
+
]
|
|
445
|
+
return [provider for provider in RESEARCH_PROFILE_ORDER.get(capability, []) if _provider_configured(provider)]
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _bilingual_search_queries(query: str) -> list[dict[str, str]]:
|
|
449
|
+
question = query.strip()
|
|
450
|
+
return [
|
|
451
|
+
{
|
|
452
|
+
"locale": "zh",
|
|
453
|
+
"label": "Chinese-language sources",
|
|
454
|
+
"query": f"中文搜索,优先检索中文来源,并回答原问题:{question}",
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
"locale": "en",
|
|
458
|
+
"label": "English-language sources",
|
|
459
|
+
"query": f"Search English-language sources and answer the original question: {question}",
|
|
460
|
+
},
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _safe_provider_overrides() -> tuple[list[str], list[str], list[str]]:
|
|
465
|
+
known = set(PROVIDER_PROFILES)
|
|
466
|
+
preferred = [provider for provider in config.research_preferred_providers if provider in known]
|
|
467
|
+
disabled = [provider for provider in config.research_disabled_providers if provider in known]
|
|
468
|
+
invalid = [
|
|
469
|
+
provider
|
|
470
|
+
for provider in config.research_preferred_providers + config.research_disabled_providers
|
|
471
|
+
if provider not in known
|
|
472
|
+
]
|
|
473
|
+
return preferred, disabled, invalid
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _apply_research_overrides(capability: str, providers: list[str]) -> list[str]:
|
|
477
|
+
preferred, disabled, _ = _safe_provider_overrides()
|
|
478
|
+
allowed = [
|
|
479
|
+
provider
|
|
480
|
+
for provider in providers
|
|
481
|
+
if provider not in disabled and _provider_supports_capability(provider, capability)
|
|
482
|
+
]
|
|
483
|
+
ordered = [
|
|
484
|
+
provider
|
|
485
|
+
for provider in preferred
|
|
486
|
+
if provider in allowed and _provider_supports_capability(provider, capability)
|
|
487
|
+
]
|
|
488
|
+
ordered.extend(provider for provider in allowed if provider not in ordered)
|
|
489
|
+
return ordered
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _research_fetch_order(query: str, url: str = "", capability_status: dict[str, Any] | None = None) -> list[str]:
|
|
493
|
+
providers = _configured_for_capability("web_fetch", capability_status)
|
|
494
|
+
target = f"{query} {url}".lower()
|
|
495
|
+
if _contains_any(target, RESEARCH_JS_HEAVY_KEYWORDS):
|
|
496
|
+
preferred = ["firecrawl", "tavily", "jina"]
|
|
497
|
+
elif _contains_any(target, RESEARCH_PDF_KEYWORDS) or url.lower().endswith(".pdf"):
|
|
498
|
+
preferred = ["jina", "tavily", "firecrawl"]
|
|
499
|
+
elif url or _extract_urls(query):
|
|
500
|
+
preferred = ["jina", "tavily", "firecrawl"]
|
|
501
|
+
else:
|
|
502
|
+
preferred = providers
|
|
503
|
+
ordered = [provider for provider in preferred if provider in providers]
|
|
504
|
+
ordered.extend(provider for provider in providers if provider not in ordered)
|
|
505
|
+
return _apply_research_overrides("web_fetch", ordered)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _research_route_signals(question: str, plan: dict[str, Any]) -> dict[str, Any]:
|
|
509
|
+
intent = plan.get("intent_signals") or {}
|
|
510
|
+
text = question.lower()
|
|
511
|
+
return {
|
|
512
|
+
"docs_api_intent": bool(intent.get("docs_api_intent")) or _is_docs_intent(question),
|
|
513
|
+
"official_low_noise_intent": _contains_any(question, DEEP_EXA_DISCOVERY_KEYWORDS),
|
|
514
|
+
"current_or_locale_intent": intent.get("recency_requirement") in {"recent", "current"}
|
|
515
|
+
or intent.get("locale_domain_scope") == "china"
|
|
516
|
+
or _is_zh_current_intent(question),
|
|
517
|
+
"known_url": bool(intent.get("known_url")) or bool(_extract_urls(question)),
|
|
518
|
+
"pdf_or_arxiv_intent": _contains_any(question, RESEARCH_PDF_KEYWORDS),
|
|
519
|
+
"js_heavy_intent": _contains_any(question, RESEARCH_JS_HEAVY_KEYWORDS),
|
|
520
|
+
"claim_risk": intent.get("claim_risk", "medium"),
|
|
521
|
+
"cross_validation_need": intent.get("cross_validation_need", "normal"),
|
|
522
|
+
"raw_query": text,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _research_capability_routes(
|
|
527
|
+
question: str,
|
|
528
|
+
plan: dict[str, Any],
|
|
529
|
+
fallback: str,
|
|
530
|
+
capability_status: dict[str, Any] | None = None,
|
|
531
|
+
) -> dict[str, Any]:
|
|
532
|
+
signals = _research_route_signals(question, plan)
|
|
533
|
+
_, _, invalid_overrides = _safe_provider_overrides()
|
|
534
|
+
routes: dict[str, Any] = {
|
|
535
|
+
"signals": signals,
|
|
536
|
+
"fallback_mode": fallback,
|
|
537
|
+
"route_policy_version": RESEARCH_ROUTE_POLICY_VERSION,
|
|
538
|
+
"invalid_provider_overrides": invalid_overrides,
|
|
539
|
+
"capabilities": {},
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
web_search = _configured_for_capability("web_search", capability_status)
|
|
543
|
+
ordered = [provider for provider in ["tavily", "firecrawl"] if provider in web_search]
|
|
544
|
+
routes["capabilities"]["web_search"] = {
|
|
545
|
+
"providers": _apply_research_overrides("web_search", ordered),
|
|
546
|
+
"reason": (
|
|
547
|
+
"bilingual current/locale evidence"
|
|
548
|
+
if signals["current_or_locale_intent"]
|
|
549
|
+
else "bilingual broad source discovery"
|
|
550
|
+
),
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
docs = _configured_for_capability("docs_search", capability_status)
|
|
554
|
+
docs_order = [provider for provider in ["context7", "exa"] if provider in docs]
|
|
555
|
+
if signals["official_low_noise_intent"] and not signals["docs_api_intent"]:
|
|
556
|
+
docs_order = [provider for provider in ["exa", "context7"] if provider in docs]
|
|
557
|
+
routes["capabilities"]["docs_search"] = {
|
|
558
|
+
"providers": _apply_research_overrides("docs_search", docs_order),
|
|
559
|
+
"reason": "docs/API evidence" if signals["docs_api_intent"] else "official low-noise discovery",
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
fetch_order = _research_fetch_order(question, capability_status=capability_status)
|
|
563
|
+
routes["capabilities"]["web_fetch"] = {
|
|
564
|
+
"providers": fetch_order,
|
|
565
|
+
"reason": "JS-heavy fetch" if signals["js_heavy_intent"] else ("known URL/PDF extraction" if signals["known_url"] or signals["pdf_or_arxiv_intent"] else "evidence extraction"),
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return routes
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _research_evidence_item(
|
|
572
|
+
*,
|
|
573
|
+
url: str,
|
|
574
|
+
provider: str,
|
|
575
|
+
title: str = "",
|
|
576
|
+
content: str = "",
|
|
577
|
+
source_type: str = "fetched_page",
|
|
578
|
+
subquestion_id: str = "",
|
|
579
|
+
) -> dict[str, Any]:
|
|
580
|
+
digest = hashlib.sha1(f"{url}\n{provider}\n{title}".encode("utf-8")).hexdigest()[:12]
|
|
581
|
+
return {
|
|
582
|
+
"id": f"e{digest}",
|
|
583
|
+
"url": url,
|
|
584
|
+
"title": title or url,
|
|
585
|
+
"provider": provider,
|
|
586
|
+
"source_type": source_type,
|
|
587
|
+
"subquestion_id": subquestion_id,
|
|
588
|
+
"content": content,
|
|
589
|
+
"content_len": len(content or ""),
|
|
590
|
+
"verified": bool(content and content.strip()),
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _citation_items(evidence_items: list[dict[str, Any]]) -> list[dict[str, str]]:
|
|
595
|
+
citations: list[dict[str, str]] = []
|
|
596
|
+
seen: set[str] = set()
|
|
597
|
+
for item in evidence_items:
|
|
598
|
+
url = item.get("url", "")
|
|
599
|
+
if not url or url in seen:
|
|
600
|
+
continue
|
|
601
|
+
seen.add(url)
|
|
602
|
+
citations.append({
|
|
603
|
+
"url": url,
|
|
604
|
+
"title": item.get("title") or url,
|
|
605
|
+
"provider": item.get("provider") or "",
|
|
606
|
+
})
|
|
607
|
+
return citations
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _evidence_only_synthesis(question: str, evidence_items: list[dict[str, Any]], gaps: list[dict[str, Any]]) -> str:
|
|
611
|
+
if not evidence_items:
|
|
612
|
+
return (
|
|
613
|
+
f"未能为 `{question}` 获取可引用的页面正文证据。"
|
|
614
|
+
"本次 research 已停止在降级状态,未对缺证据的结论做断言。"
|
|
615
|
+
)
|
|
616
|
+
lines = [f"Research result for: {question}", ""]
|
|
617
|
+
lines.append("Evidence-backed findings:")
|
|
618
|
+
for index, item in enumerate(evidence_items, 1):
|
|
619
|
+
content = re.sub(r"\s+", " ", (item.get("content") or "").strip())
|
|
620
|
+
excerpt = content[:360]
|
|
621
|
+
lines.append(f"{index}. {item.get('title') or item.get('url')} ({item.get('provider')})")
|
|
622
|
+
if excerpt:
|
|
623
|
+
lines.append(f" Evidence excerpt: {excerpt}")
|
|
624
|
+
lines.append(f" Source: {item.get('url')}")
|
|
625
|
+
if gaps:
|
|
626
|
+
lines.extend(["", "Unverified gaps:"])
|
|
627
|
+
for gap in gaps:
|
|
628
|
+
lines.append(f"- {gap.get('subquestion_id', '')}: {gap.get('reason', '')}")
|
|
629
|
+
return "\n".join(lines).strip()
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _select_candidate_urls(sources: list[dict[str, Any]], limit: int = 5) -> list[dict[str, Any]]:
|
|
633
|
+
selected: list[dict[str, Any]] = []
|
|
634
|
+
seen: set[str] = set()
|
|
635
|
+
for source in sources:
|
|
636
|
+
url = (source.get("url") or "").strip()
|
|
637
|
+
if not url or url.startswith("context7:") or url in seen:
|
|
638
|
+
continue
|
|
639
|
+
seen.add(url)
|
|
640
|
+
selected.append(source)
|
|
641
|
+
if len(selected) >= limit:
|
|
642
|
+
break
|
|
643
|
+
return selected
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _artifact_path(evidence_root: str, name: str) -> Path:
|
|
647
|
+
return Path(evidence_root) / name
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _write_research_artifact(evidence_root: str, name: str, data: Any) -> None:
|
|
651
|
+
root = Path(evidence_root)
|
|
652
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
653
|
+
path = _artifact_path(evidence_root, name)
|
|
654
|
+
if isinstance(data, str):
|
|
655
|
+
path.write_text(data, encoding="utf-8")
|
|
656
|
+
else:
|
|
657
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _is_docs_intent(query: str) -> bool:
|
|
661
|
+
q = query.lower()
|
|
662
|
+
if any(keyword in q for keyword in DOCS_INTENT_TEXT_KEYWORDS):
|
|
663
|
+
return True
|
|
664
|
+
for keyword in DOCS_INTENT_ASCII_KEYWORDS:
|
|
665
|
+
pattern = re.escape(keyword).replace(r"\ ", r"\s+")
|
|
666
|
+
if re.search(rf"(?<![a-z0-9_]){pattern}(?![a-z0-9_])", q):
|
|
667
|
+
return True
|
|
668
|
+
return False
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _is_zh_current_intent(query: str) -> bool:
|
|
672
|
+
q = query.lower()
|
|
673
|
+
return any(keyword in q for keyword in ZH_CURRENT_KEYWORDS)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _is_fetch_intent(query: str) -> bool:
|
|
677
|
+
q = query.lower()
|
|
678
|
+
return any(keyword in q for keyword in FETCH_INTENT_KEYWORDS)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _contains_any(query: str, keywords: set[str]) -> bool:
|
|
682
|
+
q = query.lower()
|
|
683
|
+
return any(keyword.lower() in q for keyword in keywords)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _extract_urls(query: str) -> list[str]:
|
|
687
|
+
urls = []
|
|
688
|
+
for match in re.findall(r"https?://[^\s<>\]\)\"']+", query):
|
|
689
|
+
cleaned = match.rstrip(".,;,。;)")
|
|
690
|
+
if cleaned:
|
|
691
|
+
urls.append(cleaned)
|
|
692
|
+
return urls
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _slugify_query(query: str) -> str:
|
|
696
|
+
slug = re.sub(r"https?://", "", query.lower())
|
|
697
|
+
slug = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", slug, flags=re.IGNORECASE)
|
|
698
|
+
slug = slug.strip("-")
|
|
699
|
+
return slug[:48] or "deep-research"
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _default_evidence_dir(query: str) -> str:
|
|
703
|
+
timestamp = time.strftime("%Y%m%d-%H%M")
|
|
704
|
+
return str(config.evidence_dir / f"{timestamp}-{_slugify_query(query)}")
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _quote_arg(value: str) -> str:
|
|
708
|
+
escaped = value.replace("`", "``").replace("$", "`$").replace('"', '`"')
|
|
709
|
+
return f'"{escaped}"'
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _path_join(base: str, filename: str) -> str:
|
|
713
|
+
return str(Path(base) / filename)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _deep_step(
|
|
717
|
+
step_id: str,
|
|
718
|
+
subquestion_id: str,
|
|
719
|
+
tool: str,
|
|
720
|
+
purpose: str,
|
|
721
|
+
command: str,
|
|
722
|
+
output_path: str,
|
|
723
|
+
) -> dict[str, str]:
|
|
724
|
+
return {
|
|
725
|
+
"id": step_id,
|
|
726
|
+
"subquestion_id": subquestion_id,
|
|
727
|
+
"tool": tool,
|
|
728
|
+
"purpose": purpose,
|
|
729
|
+
"command": command,
|
|
730
|
+
"output_path": output_path,
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _deep_capability(capability: str, tools: list[str], reason: str) -> dict[str, Any]:
|
|
735
|
+
return {"capability": capability, "tools": tools, "reason": reason}
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _deep_subquestion(sub_id: str, question: str, reason: str, required_capabilities: list[str]) -> dict[str, Any]:
|
|
739
|
+
return {
|
|
740
|
+
"id": sub_id,
|
|
741
|
+
"question": question,
|
|
742
|
+
"reason": reason,
|
|
743
|
+
"required_capabilities": required_capabilities,
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _deep_budget(value: str) -> str:
|
|
748
|
+
budget = (value or "standard").strip().lower()
|
|
749
|
+
return budget if budget in {"quick", "standard", "deep"} else "standard"
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _is_deep_complex(query: str, budget: str) -> bool:
|
|
753
|
+
q = re.sub(r"https?://[^\s<>\]\)\"']+", "", query)
|
|
754
|
+
object_separators = len(re.findall(r"[/、,,]| 和 | 与 | vs | VS | versus ", q))
|
|
755
|
+
return budget == "deep" or _contains_any(query, DEEP_HIGH_COMPLEXITY_KEYWORDS) or object_separators >= 2
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def build_deep_research_plan(query: str, budget: str = "standard", evidence_dir: str = "") -> dict[str, Any]:
|
|
759
|
+
start = time.time()
|
|
760
|
+
question = query.strip()
|
|
761
|
+
budget = _deep_budget(budget)
|
|
762
|
+
evidence_root = evidence_dir.strip() or _default_evidence_dir(question)
|
|
763
|
+
urls = _extract_urls(question)
|
|
764
|
+
known_url = bool(urls)
|
|
765
|
+
docs_intent = _is_docs_intent(question)
|
|
766
|
+
zh_current_intent = _is_zh_current_intent(question)
|
|
767
|
+
recency_requirement = "none"
|
|
768
|
+
if _contains_any(question, DEEP_CURRENT_KEYWORDS) or zh_current_intent:
|
|
769
|
+
recency_requirement = "current"
|
|
770
|
+
elif _contains_any(question, {"行情", "价格", "走势", "币圈", "股票", "市场"}) and _contains_any(question, DEEP_RECENT_KEYWORDS):
|
|
771
|
+
recency_requirement = "current"
|
|
772
|
+
elif _contains_any(question, DEEP_RECENT_KEYWORDS):
|
|
773
|
+
recency_requirement = "recent"
|
|
774
|
+
locale_domain_scope = "china" if _contains_any(question, DEEP_CHINA_KEYWORDS) else "global"
|
|
775
|
+
if known_url:
|
|
776
|
+
locale_domain_scope = "known_domains"
|
|
777
|
+
claim_risk = "high" if recency_requirement in {"recent", "current"} or _contains_any(question, {"核验", "验证", "真假", "价格", "行情", "财经", "医疗", "政策", "监管", "risk"}) else "medium"
|
|
778
|
+
cross_validation_need = "high" if claim_risk == "high" or _contains_any(question, {"对比", "选型", "核验", "验证", "compare", "versus"}) else "normal"
|
|
779
|
+
authority_need = "high" if docs_intent or claim_risk == "high" or _contains_any(question, {"官方", "文档", "论文", "标准", "政策", "监管", "official"}) else "normal"
|
|
780
|
+
complex_query = _is_deep_complex(question, budget)
|
|
781
|
+
difficulty = "high" if complex_query else "standard"
|
|
782
|
+
|
|
783
|
+
intent_signals = {
|
|
784
|
+
"recency_requirement": recency_requirement,
|
|
785
|
+
"docs_api_intent": docs_intent,
|
|
786
|
+
"locale_domain_scope": locale_domain_scope,
|
|
787
|
+
"known_url": known_url,
|
|
788
|
+
"source_authority_need": authority_need,
|
|
789
|
+
"claim_risk": claim_risk,
|
|
790
|
+
"cross_validation_need": cross_validation_need,
|
|
791
|
+
"breadth_depth_budget": budget,
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
decomposition: list[dict[str, Any]] = []
|
|
795
|
+
capability_plan: list[dict[str, Any]] = []
|
|
796
|
+
steps: list[dict[str, str]] = []
|
|
797
|
+
|
|
798
|
+
def add_step(sub_id: str, tool: str, purpose: str, command: str, filename: str) -> None:
|
|
799
|
+
step_id = f"s{len(steps) + 1}"
|
|
800
|
+
steps.append(_deep_step(step_id, sub_id, tool, purpose, command, _path_join(evidence_root, filename)))
|
|
801
|
+
|
|
802
|
+
def next_filename(suffix: str) -> str:
|
|
803
|
+
return f"{len(steps) + 1:02d}-{suffix}"
|
|
804
|
+
|
|
805
|
+
def command_search(q: str, extra_sources: int = 2, filename: str = "") -> str:
|
|
806
|
+
output_name = filename or next_filename("search.json")
|
|
807
|
+
return f"smart-search search {_quote_arg(q)} --validation balanced --extra-sources {extra_sources} --format json --output {_quote_arg(_path_join(evidence_root, output_name))}"
|
|
808
|
+
|
|
809
|
+
def command_exa(q: str) -> str:
|
|
810
|
+
return f"smart-search exa-search {_quote_arg(q)} --num-results 5 --format json --output {_quote_arg(_path_join(evidence_root, next_filename('exa.json')))}"
|
|
811
|
+
|
|
812
|
+
def command_fetch(target: str = "<key-url>") -> str:
|
|
813
|
+
return f"smart-search fetch {_quote_arg(target)} --format markdown --output {_quote_arg(_path_join(evidence_root, next_filename('fetch.md')))}"
|
|
814
|
+
|
|
815
|
+
def add_bilingual_search_steps(sub_id: str, purpose: str, extra_sources: int) -> None:
|
|
816
|
+
for variant in _bilingual_search_queries(question):
|
|
817
|
+
filename = next_filename(f"search-{variant['locale']}.json")
|
|
818
|
+
add_step(
|
|
819
|
+
sub_id,
|
|
820
|
+
"search",
|
|
821
|
+
f"{purpose}: {variant['label']}",
|
|
822
|
+
command_search(variant["query"], extra_sources, filename),
|
|
823
|
+
filename,
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
def has_capability(name: str) -> bool:
|
|
827
|
+
return any(item.get("capability") == name for item in capability_plan)
|
|
828
|
+
|
|
829
|
+
if known_url:
|
|
830
|
+
url = urls[0]
|
|
831
|
+
parsed = urlparse(url)
|
|
832
|
+
host = parsed.netloc or "provided URL"
|
|
833
|
+
decomposition.append(
|
|
834
|
+
_deep_subquestion(
|
|
835
|
+
"sq1",
|
|
836
|
+
f"这个已知来源页面本身说了什么?{url}",
|
|
837
|
+
"用户已经给出 URL,Deep Research 必须先抓正文再扩展。",
|
|
838
|
+
["page_evidence"],
|
|
839
|
+
)
|
|
840
|
+
)
|
|
841
|
+
decomposition.append(
|
|
842
|
+
_deep_subquestion(
|
|
843
|
+
"sq2",
|
|
844
|
+
f"围绕 {host} 还需要哪些相邻来源或交叉来源?",
|
|
845
|
+
"已知好 URL 适合用相似页面和广泛发现扩展证据。",
|
|
846
|
+
["adjacent_source_discovery", "broad_discovery"],
|
|
847
|
+
)
|
|
848
|
+
)
|
|
849
|
+
capability_plan.extend(
|
|
850
|
+
[
|
|
851
|
+
_deep_capability("page_evidence", ["fetch"], "Fetch the user-provided URL before making claims."),
|
|
852
|
+
_deep_capability("adjacent_source_discovery", ["exa-similar"], "Find pages adjacent to the known source."),
|
|
853
|
+
_deep_capability("broad_discovery", ["search"], "Broaden the context if the fetched page leaves gaps."),
|
|
854
|
+
]
|
|
855
|
+
)
|
|
856
|
+
add_step("sq1", "fetch", "fetch user supplied URL first", f"smart-search fetch {_quote_arg(url)} --format markdown --output {_quote_arg(_path_join(evidence_root, '01-fetch.md'))}", "01-fetch.md")
|
|
857
|
+
add_step("sq2", "exa-similar", "find adjacent sources from the provided URL", f"smart-search exa-similar {_quote_arg(url)} --num-results 5 --format json --output {_quote_arg(_path_join(evidence_root, '02-similar.json'))}", "02-similar.json")
|
|
858
|
+
add_bilingual_search_steps("sq2", "broad discovery for missing context", 1)
|
|
859
|
+
else:
|
|
860
|
+
decomposition.append(
|
|
861
|
+
_deep_subquestion(
|
|
862
|
+
"sq1",
|
|
863
|
+
f"{question} 的整体问题轮廓和候选来源是什么?",
|
|
864
|
+
"先做 broad discovery,避免一开始把问题拆错。",
|
|
865
|
+
["broad_discovery"],
|
|
866
|
+
)
|
|
867
|
+
)
|
|
868
|
+
capability_plan.append(_deep_capability("broad_discovery", ["search"], "Find the initial answer shape and candidate sources."))
|
|
869
|
+
add_bilingual_search_steps("sq1", "bilingual broad discovery and routing metadata", 1 if budget == "quick" else 3)
|
|
870
|
+
|
|
871
|
+
if docs_intent:
|
|
872
|
+
decomposition.append(
|
|
873
|
+
_deep_subquestion(
|
|
874
|
+
"sq2",
|
|
875
|
+
f"{question} 的官方文档、API 或 SDK 证据在哪里?",
|
|
876
|
+
"docs/API intent should resolve the library docs first, with Exa only as official-domain discovery.",
|
|
877
|
+
["docs_source_discovery", "page_evidence"],
|
|
878
|
+
)
|
|
879
|
+
)
|
|
880
|
+
capability_plan.append(
|
|
881
|
+
_deep_capability(
|
|
882
|
+
"docs_source_discovery",
|
|
883
|
+
["context7-library", "context7-docs"],
|
|
884
|
+
"Resolve official library/API documentation first; use Exa only for official-domain or supplemental discovery.",
|
|
885
|
+
)
|
|
886
|
+
)
|
|
887
|
+
library_hint = " ".join(re.findall(r"[A-Za-z][A-Za-z0-9_.-]*", question)[:2]) or "<library-name>"
|
|
888
|
+
add_step(
|
|
889
|
+
"sq2",
|
|
890
|
+
"context7-library",
|
|
891
|
+
"resolve library id for docs/API intent",
|
|
892
|
+
f"smart-search context7-library {_quote_arg(library_hint)} {_quote_arg(question)} --format json --output {_quote_arg(_path_join(evidence_root, next_filename('context7-library.json')))}",
|
|
893
|
+
next_filename("context7-library.json"),
|
|
894
|
+
)
|
|
895
|
+
add_step(
|
|
896
|
+
"sq2",
|
|
897
|
+
"context7-docs",
|
|
898
|
+
"retrieve docs after selecting the best library_id",
|
|
899
|
+
f"smart-search context7-docs {_quote_arg('<library_id>')} {_quote_arg(question)} --format json --output {_quote_arg(_path_join(evidence_root, next_filename('context7-docs.json')))}",
|
|
900
|
+
next_filename("context7-docs.json"),
|
|
901
|
+
)
|
|
902
|
+
if _contains_any(question, DEEP_EXA_DISCOVERY_KEYWORDS):
|
|
903
|
+
capability_plan.append(
|
|
904
|
+
_deep_capability(
|
|
905
|
+
"official_domain_discovery",
|
|
906
|
+
["exa-search"],
|
|
907
|
+
"Use Exa for official-domain or low-noise supplemental docs discovery.",
|
|
908
|
+
)
|
|
909
|
+
)
|
|
910
|
+
add_step("sq2", "exa-search", "official-domain docs source discovery", command_exa(f"{question} official docs"), next_filename("exa.json"))
|
|
911
|
+
|
|
912
|
+
if recency_requirement != "none" or locale_domain_scope == "china":
|
|
913
|
+
sub_id = f"sq{len(decomposition) + 1}"
|
|
914
|
+
decomposition.append(
|
|
915
|
+
_deep_subquestion(
|
|
916
|
+
sub_id,
|
|
917
|
+
f"{question} 的最新或中文/国内来源如何交叉验证?",
|
|
918
|
+
"Current or China-scoped prompts use bilingual search instead of Zhipu reinforcement.",
|
|
919
|
+
["current_or_locale_source_discovery"],
|
|
920
|
+
)
|
|
921
|
+
)
|
|
922
|
+
capability_plan.append(
|
|
923
|
+
_deep_capability("current_or_locale_source_discovery", ["search"], "Use Chinese and English broad search for current or locale-sensitive evidence.")
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
if complex_query:
|
|
927
|
+
while len(decomposition) < (2 if budget != "deep" else 4):
|
|
928
|
+
sub_id = f"sq{len(decomposition) + 1}"
|
|
929
|
+
if len(decomposition) == 1:
|
|
930
|
+
sub_question = f"{question} 里有哪些主要选项、说法或路线需要分别验证?"
|
|
931
|
+
reason = "Complex prompts need explicit comparison targets before final synthesis."
|
|
932
|
+
caps = ["cross_validation"]
|
|
933
|
+
elif len(decomposition) == 2:
|
|
934
|
+
sub_question = f"{question} 的成本、风险、限制和适用边界是什么?"
|
|
935
|
+
reason = "High-difficulty research needs downside and boundary checks."
|
|
936
|
+
caps = ["low_noise_source_discovery", "page_evidence"]
|
|
937
|
+
else:
|
|
938
|
+
sub_question = f"基于已抓取证据,{question} 应该如何形成可执行结论?"
|
|
939
|
+
reason = "A deep budget should reserve one synthesis-oriented gap check subquestion."
|
|
940
|
+
caps = ["gap_check"]
|
|
941
|
+
decomposition.append(_deep_subquestion(sub_id, sub_question, reason, caps))
|
|
942
|
+
if not has_capability("cross_validation"):
|
|
943
|
+
capability_plan.append(
|
|
944
|
+
_deep_capability("cross_validation", ["search"], "Compare independent sources before final claims; supplemental tools depend on intent.")
|
|
945
|
+
)
|
|
946
|
+
if budget == "deep" and _contains_any(question, DEEP_EXA_DISCOVERY_KEYWORDS):
|
|
947
|
+
add_step("sq3", "exa-search", "low-noise evidence for tradeoffs and risks", command_exa(f"{question} risks limitations comparison"), next_filename("exa.json"))
|
|
948
|
+
|
|
949
|
+
if cross_validation_need == "high":
|
|
950
|
+
if not has_capability("cross_validation"):
|
|
951
|
+
capability_plan.append(
|
|
952
|
+
_deep_capability("cross_validation", ["search"], "Compare independent sources before final claims; supplemental tools depend on intent.")
|
|
953
|
+
)
|
|
954
|
+
target_subquestion = decomposition[-1]["id"] if decomposition else "sq1"
|
|
955
|
+
cross_validation_tools = next((item["tools"] for item in capability_plan if item.get("capability") == "cross_validation"), [])
|
|
956
|
+
if recency_requirement != "none" or locale_domain_scope == "china" or zh_current_intent:
|
|
957
|
+
if "search" not in cross_validation_tools:
|
|
958
|
+
cross_validation_tools.append("search")
|
|
959
|
+
if not any(
|
|
960
|
+
step["tool"] == "search" and "English-language sources" in step.get("purpose", "")
|
|
961
|
+
for step in steps
|
|
962
|
+
):
|
|
963
|
+
add_bilingual_search_steps(target_subquestion, "current or locale-specific cross-source discovery", 2)
|
|
964
|
+
elif docs_intent:
|
|
965
|
+
if "context7-library" not in cross_validation_tools:
|
|
966
|
+
cross_validation_tools.extend(["context7-library", "context7-docs"])
|
|
967
|
+
elif _contains_any(question, DEEP_EXA_DISCOVERY_KEYWORDS):
|
|
968
|
+
if "exa-search" not in cross_validation_tools:
|
|
969
|
+
cross_validation_tools.append("exa-search")
|
|
970
|
+
if not any(step["tool"] == "exa-search" for step in steps):
|
|
971
|
+
add_step(target_subquestion, "exa-search", "official-domain or low-noise cross-source discovery", command_exa(question), next_filename("exa.json"))
|
|
972
|
+
|
|
973
|
+
capability_plan.append(_deep_capability("page_evidence", ["fetch"], "Fetch key URLs before claim-level conclusions."))
|
|
974
|
+
add_step("sq1" if len(decomposition) == 1 else decomposition[-1]["id"], "fetch", "fetch key URLs before final claims", command_fetch(), next_filename("fetch.md"))
|
|
975
|
+
|
|
976
|
+
for item in capability_plan:
|
|
977
|
+
item["tools"] = [tool for tool in item["tools"] if tool in DEEP_ALLOWED_TOOLS]
|
|
978
|
+
steps = [step for step in steps if step["tool"] in DEEP_ALLOWED_TOOLS]
|
|
979
|
+
if budget == "quick" and len(decomposition) > 2:
|
|
980
|
+
decomposition = decomposition[:2]
|
|
981
|
+
if budget == "quick" and len(steps) > 4:
|
|
982
|
+
limited_steps = steps[:4]
|
|
983
|
+
if not any(step["tool"] == "fetch" for step in limited_steps):
|
|
984
|
+
first_fetch = next((step for step in steps if step["tool"] == "fetch"), None)
|
|
985
|
+
if first_fetch:
|
|
986
|
+
first_fetch = dict(first_fetch)
|
|
987
|
+
fetch_path = _path_join(evidence_root, "04-fetch.md")
|
|
988
|
+
first_fetch["command"] = f"smart-search fetch {_quote_arg('<key-url>')} --format markdown --output {_quote_arg(fetch_path)}"
|
|
989
|
+
first_fetch["output_path"] = fetch_path
|
|
990
|
+
limited_steps = steps[:3] + [first_fetch]
|
|
991
|
+
steps = limited_steps[:4]
|
|
992
|
+
if budget == "quick":
|
|
993
|
+
valid_subquestion_ids = {item["id"] for item in decomposition}
|
|
994
|
+
fallback_subquestion_id = decomposition[-1]["id"] if decomposition else "sq1"
|
|
995
|
+
for index, step in enumerate(steps, start=1):
|
|
996
|
+
step["id"] = f"s{index}"
|
|
997
|
+
if step.get("subquestion_id") not in valid_subquestion_ids:
|
|
998
|
+
step["subquestion_id"] = fallback_subquestion_id
|
|
999
|
+
|
|
1000
|
+
return {
|
|
1001
|
+
"ok": True,
|
|
1002
|
+
"mode": "deep_research",
|
|
1003
|
+
"query_mode": "research",
|
|
1004
|
+
"question": question,
|
|
1005
|
+
"trigger_source": "explicit_cli",
|
|
1006
|
+
"difficulty": difficulty,
|
|
1007
|
+
"intent_signals": intent_signals,
|
|
1008
|
+
"decomposition": decomposition,
|
|
1009
|
+
"capability_plan": capability_plan,
|
|
1010
|
+
"evidence_policy": "fetch_before_claim",
|
|
1011
|
+
"preflight": {
|
|
1012
|
+
"tool": "doctor",
|
|
1013
|
+
"command": "smart-search doctor --format json",
|
|
1014
|
+
"when": "configuration or provider availability is uncertain",
|
|
1015
|
+
"executed_during_planning": False,
|
|
1016
|
+
},
|
|
1017
|
+
"steps": steps,
|
|
1018
|
+
"gap_check": {
|
|
1019
|
+
"required": True,
|
|
1020
|
+
"rule": "fetch missing evidence for key claims or downgrade unsupported claims to unverified candidates",
|
|
1021
|
+
"unsupported_claim_action": "downgrade_to_unverified_candidate",
|
|
1022
|
+
},
|
|
1023
|
+
"final_answer_policy": "cite fetched evidence, list unverified candidates, and include key commands",
|
|
1024
|
+
"usage_boundary": {
|
|
1025
|
+
"search": "smart-search search runs live fast/broad search immediately.",
|
|
1026
|
+
"research": "smart-search research builds this plan internally, then runs the staged discover/fetch/synthesis workflow.",
|
|
1027
|
+
"execution": "research executes the listed steps with existing CLI commands, then performs gap_check.",
|
|
1028
|
+
},
|
|
1029
|
+
"allowed_tools": sorted(DEEP_ALLOWED_TOOLS),
|
|
1030
|
+
"evidence_dir": evidence_root,
|
|
1031
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
async def research(
|
|
1036
|
+
query: str,
|
|
1037
|
+
budget: str = "deep",
|
|
1038
|
+
evidence_dir: str = "",
|
|
1039
|
+
fallback: str = "auto",
|
|
1040
|
+
) -> dict[str, Any]:
|
|
1041
|
+
start = time.time()
|
|
1042
|
+
question = query.strip()
|
|
1043
|
+
fallback_mode = (fallback or "auto").strip().lower()
|
|
1044
|
+
if fallback_mode not in {"auto", "off"}:
|
|
1045
|
+
return {
|
|
1046
|
+
"ok": False,
|
|
1047
|
+
"error_type": "parameter_error",
|
|
1048
|
+
"error": f"Invalid fallback mode: {fallback_mode}",
|
|
1049
|
+
"question": question,
|
|
1050
|
+
"mode": "deep_research_execution",
|
|
1051
|
+
"route_policy_version": RESEARCH_ROUTE_POLICY_VERSION,
|
|
1052
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
minimum = validate_minimum_profile()
|
|
1056
|
+
if not minimum.get("ok"):
|
|
1057
|
+
return {
|
|
1058
|
+
"ok": False,
|
|
1059
|
+
"error_type": minimum.get("error_type", "config_error"),
|
|
1060
|
+
"error": minimum.get("error", MINIMUM_PROFILE_ERROR),
|
|
1061
|
+
"question": question,
|
|
1062
|
+
"mode": "deep_research_execution",
|
|
1063
|
+
"minimum_profile_ok": False,
|
|
1064
|
+
"capability_status": minimum.get("capability_status", {}),
|
|
1065
|
+
"final_answer": "",
|
|
1066
|
+
"citations": [],
|
|
1067
|
+
"evidence_items": [],
|
|
1068
|
+
"gap_check": {
|
|
1069
|
+
"status": "failed",
|
|
1070
|
+
"gaps": [{"subquestion_id": "", "reason": "minimum profile is missing required capabilities"}],
|
|
1071
|
+
},
|
|
1072
|
+
"provider_attempts": [],
|
|
1073
|
+
"fallback_used": False,
|
|
1074
|
+
"degraded": True,
|
|
1075
|
+
"route_policy_version": RESEARCH_ROUTE_POLICY_VERSION,
|
|
1076
|
+
"evidence_dir": evidence_dir,
|
|
1077
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
plan = build_deep_research_plan(question, budget=_deep_budget(budget or "deep"), evidence_dir=evidence_dir)
|
|
1081
|
+
evidence_root = plan.get("evidence_dir") or _default_evidence_dir(question)
|
|
1082
|
+
routes = _research_capability_routes(question, plan, fallback_mode)
|
|
1083
|
+
provider_attempts: list[dict[str, Any]] = []
|
|
1084
|
+
discovery_sources: list[dict[str, Any]] = []
|
|
1085
|
+
evidence_items: list[dict[str, Any]] = []
|
|
1086
|
+
stage_results: list[dict[str, Any]] = []
|
|
1087
|
+
gaps: list[dict[str, Any]] = []
|
|
1088
|
+
|
|
1089
|
+
_write_research_artifact(evidence_root, "00-plan.json", plan)
|
|
1090
|
+
|
|
1091
|
+
urls = _extract_urls(question)
|
|
1092
|
+
fetch_order = routes["capabilities"]["web_fetch"]["providers"]
|
|
1093
|
+
if urls:
|
|
1094
|
+
for index, url in enumerate(urls, 1):
|
|
1095
|
+
fetch_result, attempts = await _run_web_fetch_fallback(url, fallback=fallback_mode, preferred_order=fetch_order)
|
|
1096
|
+
provider_attempts.extend(attempts)
|
|
1097
|
+
stage_results.append({"stage": "known_url_fetch", "url": url, "ok": bool(fetch_result), "provider_attempts": attempts})
|
|
1098
|
+
if fetch_result:
|
|
1099
|
+
item = _research_evidence_item(
|
|
1100
|
+
url=fetch_result["url"],
|
|
1101
|
+
provider=fetch_result["provider"],
|
|
1102
|
+
title=fetch_result["url"],
|
|
1103
|
+
content=fetch_result["content"],
|
|
1104
|
+
subquestion_id="sq1",
|
|
1105
|
+
)
|
|
1106
|
+
evidence_items.append(item)
|
|
1107
|
+
_write_research_artifact(evidence_root, f"{index:02d}-fetch-{fetch_result['provider']}.md", fetch_result["content"])
|
|
1108
|
+
else:
|
|
1109
|
+
gaps.append({"subquestion_id": "sq1", "reason": f"failed to fetch known URL: {url}", "url": url})
|
|
1110
|
+
|
|
1111
|
+
signals = routes["signals"]
|
|
1112
|
+
if signals["docs_api_intent"]:
|
|
1113
|
+
docs_providers = routes["capabilities"]["docs_search"]["providers"]
|
|
1114
|
+
selected_docs_providers = docs_providers[:1] if fallback_mode == "off" else docs_providers
|
|
1115
|
+
if not selected_docs_providers:
|
|
1116
|
+
gaps.append({"subquestion_id": "sq2", "reason": "no configured docs_search provider for docs/API evidence"})
|
|
1117
|
+
for provider in selected_docs_providers:
|
|
1118
|
+
step_start = time.time()
|
|
1119
|
+
if provider == "context7":
|
|
1120
|
+
data = await context7_library(question, question)
|
|
1121
|
+
if data.get("ok") and data.get("results"):
|
|
1122
|
+
provider_attempts.append(_attempt("docs_search", "context7", "ok", step_start, result_count=len(data.get("results") or [])))
|
|
1123
|
+
stage_results.append({"stage": "docs_discovery", "provider": "context7", "ok": True, "result_count": len(data.get("results") or [])})
|
|
1124
|
+
library_id = (data.get("results") or [{}])[0].get("id", "")
|
|
1125
|
+
if library_id:
|
|
1126
|
+
docs_start = time.time()
|
|
1127
|
+
docs_data = await context7_docs(library_id, question)
|
|
1128
|
+
if docs_data.get("ok") and docs_data.get("content"):
|
|
1129
|
+
provider_attempts.append(_attempt("docs_search", "context7", "ok", docs_start, result_count=1))
|
|
1130
|
+
item = _research_evidence_item(
|
|
1131
|
+
url=f"context7:{library_id}",
|
|
1132
|
+
provider="context7",
|
|
1133
|
+
title=library_id,
|
|
1134
|
+
content=docs_data.get("content", ""),
|
|
1135
|
+
source_type="docs",
|
|
1136
|
+
subquestion_id="sq2",
|
|
1137
|
+
)
|
|
1138
|
+
evidence_items.append(item)
|
|
1139
|
+
_write_research_artifact(evidence_root, "docs-context7.md", docs_data.get("content", ""))
|
|
1140
|
+
break
|
|
1141
|
+
docs_status = "error" if docs_data.get("error_type") else "empty"
|
|
1142
|
+
provider_attempts.append(_attempt("docs_search", "context7", docs_status, docs_start, error_type=docs_data.get("error_type", ""), error=docs_data.get("error", "")))
|
|
1143
|
+
if fallback_mode == "off":
|
|
1144
|
+
break
|
|
1145
|
+
continue
|
|
1146
|
+
status = "error" if data.get("error_type") in {"auth_error", "timeout", "network_error", "runtime_error"} else "empty"
|
|
1147
|
+
provider_attempts.append(_attempt("docs_search", "context7", status, step_start, error_type=data.get("error_type", ""), error=data.get("error", "")))
|
|
1148
|
+
elif provider == "exa":
|
|
1149
|
+
data = await exa_search(question, num_results=5, include_highlights=True)
|
|
1150
|
+
if data.get("ok"):
|
|
1151
|
+
sources = _normalize_source_results(data.get("results"), "exa")
|
|
1152
|
+
if sources:
|
|
1153
|
+
provider_attempts.append(_attempt("docs_search", "exa", "ok", step_start, result_count=len(sources)))
|
|
1154
|
+
discovery_sources.extend(sources)
|
|
1155
|
+
stage_results.append({"stage": "docs_discovery", "provider": "exa", "ok": True, "result_count": len(sources)})
|
|
1156
|
+
break
|
|
1157
|
+
provider_attempts.append(_attempt("docs_search", "exa", "error" if data.get("error_type") else "empty", step_start, error_type=data.get("error_type", ""), error=data.get("error", "")))
|
|
1158
|
+
|
|
1159
|
+
should_run_web_discovery = (
|
|
1160
|
+
signals["current_or_locale_intent"]
|
|
1161
|
+
or signals["cross_validation_need"] == "high"
|
|
1162
|
+
or (not evidence_items and not discovery_sources)
|
|
1163
|
+
) and not (urls and fallback_mode == "off")
|
|
1164
|
+
if should_run_web_discovery:
|
|
1165
|
+
web_provider_order = routes["capabilities"]["web_search"]["providers"]
|
|
1166
|
+
if web_provider_order:
|
|
1167
|
+
web_sources, attempts = await _run_bilingual_web_search(
|
|
1168
|
+
question,
|
|
1169
|
+
count=5,
|
|
1170
|
+
providers=",".join(web_provider_order),
|
|
1171
|
+
fallback=fallback_mode,
|
|
1172
|
+
)
|
|
1173
|
+
provider_attempts.extend(attempts)
|
|
1174
|
+
discovery_sources.extend(web_sources)
|
|
1175
|
+
stage_results.append({"stage": "web_discovery", "ok": bool(web_sources), "result_count": len(web_sources), "provider_attempts": attempts})
|
|
1176
|
+
else:
|
|
1177
|
+
gaps.append({"subquestion_id": "", "reason": "no configured web_search provider for discovery"})
|
|
1178
|
+
|
|
1179
|
+
exa_in_selected_docs_route = "exa" in routes["capabilities"]["docs_search"]["providers"]
|
|
1180
|
+
if (
|
|
1181
|
+
fallback_mode != "off"
|
|
1182
|
+
and signals["official_low_noise_intent"]
|
|
1183
|
+
and exa_in_selected_docs_route
|
|
1184
|
+
and not any(source.get("provider") == "exa" for source in discovery_sources)
|
|
1185
|
+
):
|
|
1186
|
+
exa_start = time.time()
|
|
1187
|
+
data = await exa_search(question, num_results=5, include_highlights=True)
|
|
1188
|
+
if data.get("ok"):
|
|
1189
|
+
sources = _normalize_source_results(data.get("results"), "exa")
|
|
1190
|
+
if sources:
|
|
1191
|
+
provider_attempts.append(_attempt("docs_search", "exa", "ok", exa_start, result_count=len(sources)))
|
|
1192
|
+
discovery_sources.extend(sources)
|
|
1193
|
+
else:
|
|
1194
|
+
provider_attempts.append(_attempt("docs_search", "exa", "error", exa_start, error_type=data.get("error_type", ""), error=data.get("error", "")))
|
|
1195
|
+
|
|
1196
|
+
candidates = _select_candidate_urls(discovery_sources, limit=6)
|
|
1197
|
+
fetched_urls = {item.get("url") for item in evidence_items}
|
|
1198
|
+
no_new_evidence = True
|
|
1199
|
+
for index, candidate in enumerate(candidates, 1):
|
|
1200
|
+
url = candidate.get("url", "")
|
|
1201
|
+
if not url or url in fetched_urls:
|
|
1202
|
+
continue
|
|
1203
|
+
order = _research_fetch_order(question, url)
|
|
1204
|
+
fetch_result, attempts = await _run_web_fetch_fallback(url, fallback=fallback_mode, preferred_order=order)
|
|
1205
|
+
provider_attempts.extend(attempts)
|
|
1206
|
+
stage_results.append({"stage": "candidate_fetch", "url": url, "ok": bool(fetch_result), "provider_attempts": attempts})
|
|
1207
|
+
if fetch_result:
|
|
1208
|
+
no_new_evidence = False
|
|
1209
|
+
fetched_urls.add(url)
|
|
1210
|
+
content = fetch_result.get("content", "")
|
|
1211
|
+
item = _research_evidence_item(
|
|
1212
|
+
url=fetch_result["url"],
|
|
1213
|
+
provider=fetch_result["provider"],
|
|
1214
|
+
title=candidate.get("title") or fetch_result["url"],
|
|
1215
|
+
content=content,
|
|
1216
|
+
subquestion_id=candidate.get("subquestion_id", ""),
|
|
1217
|
+
)
|
|
1218
|
+
evidence_items.append(item)
|
|
1219
|
+
_write_research_artifact(evidence_root, f"fetch-{index:02d}-{fetch_result['provider']}.md", content)
|
|
1220
|
+
elif fallback_mode == "off":
|
|
1221
|
+
gaps.append({"subquestion_id": "", "reason": f"fetch failed with fallback off: {url}", "url": url})
|
|
1222
|
+
|
|
1223
|
+
if not evidence_items:
|
|
1224
|
+
gaps.append({"subquestion_id": "", "reason": "no fetched/read evidence items were produced"})
|
|
1225
|
+
elif no_new_evidence and not urls and candidates:
|
|
1226
|
+
gaps.append({"subquestion_id": "", "reason": "discovery produced candidates but no new fetch evidence converged"})
|
|
1227
|
+
|
|
1228
|
+
covered = bool(evidence_items)
|
|
1229
|
+
gap_status = "closed" if covered and not gaps else ("degraded" if evidence_items else "failed")
|
|
1230
|
+
citations = _citation_items(evidence_items)
|
|
1231
|
+
final_answer = _evidence_only_synthesis(question, evidence_items, gaps)
|
|
1232
|
+
result = {
|
|
1233
|
+
"ok": bool(evidence_items),
|
|
1234
|
+
"error_type": "" if evidence_items else "evidence_error",
|
|
1235
|
+
"error": "" if evidence_items else "research could not obtain fetched evidence",
|
|
1236
|
+
"mode": "deep_research_execution",
|
|
1237
|
+
"query_mode": "research",
|
|
1238
|
+
"question": question,
|
|
1239
|
+
"budget": _deep_budget(budget or "deep"),
|
|
1240
|
+
"research_plan": plan,
|
|
1241
|
+
"routing_decision": routes,
|
|
1242
|
+
"stage_results": stage_results,
|
|
1243
|
+
"discovery_sources": discovery_sources,
|
|
1244
|
+
"final_answer": final_answer,
|
|
1245
|
+
"content": final_answer,
|
|
1246
|
+
"citations": citations,
|
|
1247
|
+
"evidence_items": evidence_items,
|
|
1248
|
+
"gap_check": {
|
|
1249
|
+
"status": gap_status,
|
|
1250
|
+
"gaps": gaps,
|
|
1251
|
+
"stop_reason": "evidence_converged" if gap_status == "closed" else ("degraded_with_gaps" if evidence_items else "provider_exhausted"),
|
|
1252
|
+
},
|
|
1253
|
+
"provider_attempts": provider_attempts,
|
|
1254
|
+
"providers_used": _provider_names_from_attempts(provider_attempts),
|
|
1255
|
+
"fallback_used": _fallback_used(provider_attempts),
|
|
1256
|
+
"degraded": bool(gaps),
|
|
1257
|
+
"route_policy_version": RESEARCH_ROUTE_POLICY_VERSION,
|
|
1258
|
+
"evidence_dir": evidence_root,
|
|
1259
|
+
"minimum_profile_ok": minimum.get("ok", False),
|
|
1260
|
+
"capability_status": minimum.get("capability_status", {}),
|
|
1261
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
1262
|
+
}
|
|
1263
|
+
_write_research_artifact(evidence_root, "summary.json", result)
|
|
1264
|
+
return result
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def get_capability_status() -> dict[str, Any]:
|
|
1268
|
+
main_configured = _configured_main_search_provider_ids()
|
|
1269
|
+
status = {
|
|
1270
|
+
"main_search": {
|
|
1271
|
+
"configured": main_configured,
|
|
1272
|
+
"fallback_chain": MAIN_SEARCH_FALLBACK_CHAIN,
|
|
1273
|
+
"ok": bool(main_configured),
|
|
1274
|
+
},
|
|
1275
|
+
"web_search": {
|
|
1276
|
+
"configured": [
|
|
1277
|
+
name
|
|
1278
|
+
for name, enabled in [
|
|
1279
|
+
("tavily", bool(config.tavily_api_key)),
|
|
1280
|
+
("firecrawl", bool(config.firecrawl_api_key)),
|
|
1281
|
+
]
|
|
1282
|
+
if enabled
|
|
1283
|
+
],
|
|
1284
|
+
"fallback_chain": ["tavily", "firecrawl"],
|
|
1285
|
+
"deprecated_configured": ["zhipu"] if config.zhipu_api_key else [],
|
|
1286
|
+
},
|
|
1287
|
+
"docs_search": {
|
|
1288
|
+
"configured": [
|
|
1289
|
+
name
|
|
1290
|
+
for name, enabled in [
|
|
1291
|
+
("context7", bool(config.context7_api_key)),
|
|
1292
|
+
("exa", bool(config.exa_api_key)),
|
|
1293
|
+
]
|
|
1294
|
+
if enabled
|
|
1295
|
+
],
|
|
1296
|
+
"fallback_chain": ["context7", "exa"],
|
|
1297
|
+
},
|
|
1298
|
+
"web_fetch": {
|
|
1299
|
+
"configured": [
|
|
1300
|
+
name
|
|
1301
|
+
for name, enabled in [
|
|
1302
|
+
("tavily", bool(config.tavily_api_key)),
|
|
1303
|
+
("jina", bool(config.jina_api_key)),
|
|
1304
|
+
("firecrawl", bool(config.firecrawl_api_key)),
|
|
1305
|
+
]
|
|
1306
|
+
if enabled
|
|
1307
|
+
],
|
|
1308
|
+
"fallback_chain": ["tavily", "jina", "firecrawl"],
|
|
1309
|
+
},
|
|
1310
|
+
}
|
|
1311
|
+
for capability in ("web_search", "docs_search", "web_fetch"):
|
|
1312
|
+
status[capability]["ok"] = bool(status[capability]["configured"])
|
|
1313
|
+
return status
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def _minimum_profile_result(profile: str, capability_status: dict[str, Any]) -> dict[str, Any]:
|
|
1317
|
+
required = [] if profile == "off" else ["main_search", "docs_search", "web_fetch"]
|
|
1318
|
+
missing = [capability for capability in required if not capability_status.get(capability, {}).get("ok")]
|
|
1319
|
+
return {
|
|
1320
|
+
"ok": not missing,
|
|
1321
|
+
"error_type": "config_error" if missing else "",
|
|
1322
|
+
"error": f"{MINIMUM_PROFILE_ERROR} 缺失能力: {', '.join(missing)}" if missing else "",
|
|
1323
|
+
"profile": profile,
|
|
1324
|
+
"required": required,
|
|
1325
|
+
"missing": missing,
|
|
1326
|
+
"capability_status": capability_status,
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def validate_minimum_profile() -> dict[str, Any]:
|
|
1331
|
+
try:
|
|
1332
|
+
profile = config.minimum_profile
|
|
1333
|
+
except ValueError as e:
|
|
1334
|
+
return {"ok": False, "error_type": "parameter_error", "error": str(e), "missing": []}
|
|
1335
|
+
return _minimum_profile_result(profile, get_capability_status())
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def _parse_provider_filter(providers: str = "auto") -> set[str] | None:
|
|
1339
|
+
if not providers or providers.strip().lower() == "auto":
|
|
1340
|
+
return None
|
|
1341
|
+
return {item.strip().lower() for item in providers.split(",") if item.strip()}
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _provider_allowed(provider_id: str, provider_filter: set[str] | None) -> bool:
|
|
1345
|
+
if provider_filter is None:
|
|
1346
|
+
return True
|
|
1347
|
+
aliases = MAIN_SEARCH_PROVIDER_ALIASES.get(provider_id, {provider_id})
|
|
1348
|
+
return bool(provider_filter.intersection(aliases))
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def _configured_main_search_provider_ids() -> list[str]:
|
|
1352
|
+
configured: set[str] = set()
|
|
1353
|
+
|
|
1354
|
+
if config.openai_compatible_api_url and config.openai_compatible_api_key:
|
|
1355
|
+
configured.add("openai-compatible")
|
|
1356
|
+
|
|
1357
|
+
return [provider for provider in MAIN_SEARCH_FALLBACK_CHAIN if provider in configured]
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
def _main_search_provider_configs(model_override: str = "", providers: str = "auto") -> list[dict[str, Any]]:
|
|
1361
|
+
provider_filter = _parse_provider_filter(providers)
|
|
1362
|
+
by_provider: dict[str, dict[str, Any]] = {}
|
|
1363
|
+
|
|
1364
|
+
if config.openai_compatible_api_url and config.openai_compatible_api_key:
|
|
1365
|
+
by_provider["openai-compatible"] = {
|
|
1366
|
+
"provider": "openai-compatible",
|
|
1367
|
+
"mode": "chat-completions",
|
|
1368
|
+
"api_url": config.openai_compatible_api_url,
|
|
1369
|
+
"api_key": config.openai_compatible_api_key,
|
|
1370
|
+
"model": model_override or config.openai_compatible_model,
|
|
1371
|
+
"stream": config.openai_compatible_stream,
|
|
1372
|
+
"tools": [],
|
|
1373
|
+
"source": "OPENAI_COMPATIBLE_*",
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
return [
|
|
1377
|
+
by_provider[provider]
|
|
1378
|
+
for provider in MAIN_SEARCH_FALLBACK_CHAIN
|
|
1379
|
+
if provider in by_provider and _provider_allowed(provider, provider_filter)
|
|
1380
|
+
]
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def _main_search_providers(provider_configs: list[dict[str, Any]], fallback: str) -> list[Any]:
|
|
1384
|
+
selected = provider_configs if fallback != "off" else provider_configs[:1]
|
|
1385
|
+
providers: list[Any] = []
|
|
1386
|
+
for provider_config in selected:
|
|
1387
|
+
providers.append(
|
|
1388
|
+
OpenAICompatibleSearchProvider(
|
|
1389
|
+
provider_config["api_url"],
|
|
1390
|
+
provider_config["api_key"],
|
|
1391
|
+
provider_config["model"],
|
|
1392
|
+
provider_config.get("stream", False),
|
|
1393
|
+
)
|
|
1394
|
+
)
|
|
1395
|
+
return providers
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
async def fetch_available_models(api_url: str, api_key: str) -> list[str]:
|
|
1399
|
+
models_url = f"{api_url.rstrip('/')}/models"
|
|
1400
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
1401
|
+
response = await client.get(
|
|
1402
|
+
models_url,
|
|
1403
|
+
headers={
|
|
1404
|
+
"Authorization": f"Bearer {api_key}",
|
|
1405
|
+
"Content-Type": "application/json",
|
|
1406
|
+
},
|
|
1407
|
+
)
|
|
1408
|
+
response.raise_for_status()
|
|
1409
|
+
data = response.json()
|
|
1410
|
+
|
|
1411
|
+
models: list[str] = []
|
|
1412
|
+
for item in (data or {}).get("data", []) or []:
|
|
1413
|
+
if isinstance(item, dict) and isinstance(item.get("id"), str):
|
|
1414
|
+
models.append(item["id"])
|
|
1415
|
+
return models
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
async def get_available_models_cached(api_url: str, api_key: str) -> list[str]:
|
|
1419
|
+
key = (api_url, api_key)
|
|
1420
|
+
async with _AVAILABLE_MODELS_LOCK:
|
|
1421
|
+
if key in _AVAILABLE_MODELS_CACHE:
|
|
1422
|
+
return _AVAILABLE_MODELS_CACHE[key]
|
|
1423
|
+
|
|
1424
|
+
try:
|
|
1425
|
+
models = await fetch_available_models(api_url, api_key)
|
|
1426
|
+
except Exception:
|
|
1427
|
+
models = []
|
|
1428
|
+
|
|
1429
|
+
async with _AVAILABLE_MODELS_LOCK:
|
|
1430
|
+
_AVAILABLE_MODELS_CACHE[key] = models
|
|
1431
|
+
return models
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def extra_results_to_sources(
|
|
1435
|
+
tavily_results: list[dict] | None,
|
|
1436
|
+
firecrawl_results: list[dict] | None,
|
|
1437
|
+
) -> list[dict]:
|
|
1438
|
+
sources: list[dict] = []
|
|
1439
|
+
seen: set[str] = set()
|
|
1440
|
+
|
|
1441
|
+
if firecrawl_results:
|
|
1442
|
+
for r in firecrawl_results:
|
|
1443
|
+
url = (r.get("url") or "").strip()
|
|
1444
|
+
if not url or url in seen:
|
|
1445
|
+
continue
|
|
1446
|
+
seen.add(url)
|
|
1447
|
+
item: dict = {"url": url, "provider": "firecrawl"}
|
|
1448
|
+
title = (r.get("title") or "").strip()
|
|
1449
|
+
if title:
|
|
1450
|
+
item["title"] = title
|
|
1451
|
+
desc = (r.get("description") or "").strip()
|
|
1452
|
+
if desc:
|
|
1453
|
+
item["description"] = desc
|
|
1454
|
+
sources.append(item)
|
|
1455
|
+
|
|
1456
|
+
if tavily_results:
|
|
1457
|
+
for r in tavily_results:
|
|
1458
|
+
url = (r.get("url") or "").strip()
|
|
1459
|
+
if not url or url in seen:
|
|
1460
|
+
continue
|
|
1461
|
+
seen.add(url)
|
|
1462
|
+
item = {"url": url, "provider": "tavily"}
|
|
1463
|
+
title = (r.get("title") or "").strip()
|
|
1464
|
+
if title:
|
|
1465
|
+
item["title"] = title
|
|
1466
|
+
content = (r.get("content") or "").strip()
|
|
1467
|
+
if content:
|
|
1468
|
+
item["description"] = content
|
|
1469
|
+
sources.append(item)
|
|
1470
|
+
|
|
1471
|
+
return sources
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
async def _run_web_fetch_fallback(
|
|
1475
|
+
url: str,
|
|
1476
|
+
fallback: str = "auto",
|
|
1477
|
+
preferred_order: list[str] | None = None,
|
|
1478
|
+
) -> tuple[dict[str, Any] | None, list[dict]]:
|
|
1479
|
+
attempts: list[dict] = []
|
|
1480
|
+
providers = []
|
|
1481
|
+
if config.tavily_api_key:
|
|
1482
|
+
providers.append("tavily")
|
|
1483
|
+
if config.jina_api_key:
|
|
1484
|
+
providers.append("jina")
|
|
1485
|
+
if config.firecrawl_api_key:
|
|
1486
|
+
providers.append("firecrawl")
|
|
1487
|
+
if preferred_order:
|
|
1488
|
+
allowed = {provider for provider in providers}
|
|
1489
|
+
ordered = [provider for provider in preferred_order if provider in allowed]
|
|
1490
|
+
ordered.extend(provider for provider in providers if provider not in ordered)
|
|
1491
|
+
providers = ordered
|
|
1492
|
+
if fallback == "off":
|
|
1493
|
+
providers = providers[:1]
|
|
1494
|
+
|
|
1495
|
+
for provider in providers:
|
|
1496
|
+
start = time.time()
|
|
1497
|
+
try:
|
|
1498
|
+
if provider == "tavily":
|
|
1499
|
+
content = await call_tavily_extract(url)
|
|
1500
|
+
elif provider == "jina":
|
|
1501
|
+
data = await jina_fetch(url)
|
|
1502
|
+
content = data.get("content") if data.get("ok") else None
|
|
1503
|
+
if not data.get("ok"):
|
|
1504
|
+
status = "error" if data.get("error_type") in {"auth_error", "config_error", "parameter_error", "quality_error", "rate_limited", "timeout", "network_error", "runtime_error"} else "empty"
|
|
1505
|
+
attempts.append(_attempt("web_fetch", provider, status, start, error_type=data.get("error_type", ""), error=data.get("error", "")))
|
|
1506
|
+
continue
|
|
1507
|
+
else:
|
|
1508
|
+
content = await call_firecrawl_scrape(url)
|
|
1509
|
+
if content and content.strip():
|
|
1510
|
+
attempts.append(_attempt("web_fetch", provider, "ok", start, result_count=1))
|
|
1511
|
+
return {
|
|
1512
|
+
"ok": True,
|
|
1513
|
+
"url": url,
|
|
1514
|
+
"provider": provider,
|
|
1515
|
+
"content": content,
|
|
1516
|
+
}, attempts
|
|
1517
|
+
attempts.append(_attempt("web_fetch", provider, "empty", start))
|
|
1518
|
+
except Exception as e:
|
|
1519
|
+
attempts.append(_attempt("web_fetch", provider, "error", start, error_type="runtime_error", error=str(e)))
|
|
1520
|
+
return None, attempts
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
async def _run_web_search_fallback(
|
|
1524
|
+
query: str,
|
|
1525
|
+
count: int = 5,
|
|
1526
|
+
providers: str = "auto",
|
|
1527
|
+
fallback: str = "auto",
|
|
1528
|
+
) -> tuple[list[dict], list[dict]]:
|
|
1529
|
+
provider_filter = _parse_provider_filter(providers)
|
|
1530
|
+
attempts: list[dict] = []
|
|
1531
|
+
configured: list[str] = []
|
|
1532
|
+
if config.tavily_api_key:
|
|
1533
|
+
configured.append("tavily")
|
|
1534
|
+
if config.firecrawl_api_key:
|
|
1535
|
+
configured.append("firecrawl")
|
|
1536
|
+
if provider_filter is not None and "zhipu" in provider_filter and config.zhipu_api_key:
|
|
1537
|
+
configured.append("zhipu")
|
|
1538
|
+
if provider_filter is not None:
|
|
1539
|
+
configured = [p for p in configured if p in provider_filter]
|
|
1540
|
+
if fallback == "off":
|
|
1541
|
+
configured = configured[:1]
|
|
1542
|
+
|
|
1543
|
+
for provider in configured:
|
|
1544
|
+
start = time.time()
|
|
1545
|
+
try:
|
|
1546
|
+
if provider == "zhipu":
|
|
1547
|
+
data = await zhipu_search(query, count=count)
|
|
1548
|
+
if data.get("ok"):
|
|
1549
|
+
sources = _normalize_source_results(data.get("results"), "zhipu")
|
|
1550
|
+
if sources:
|
|
1551
|
+
attempts.append(_attempt("web_search", provider, "ok", start, result_count=len(sources)))
|
|
1552
|
+
return sources, attempts
|
|
1553
|
+
status = "error" if data.get("error_type") in {"rate_limited", "auth_error", "timeout", "network_error", "runtime_error"} else "empty"
|
|
1554
|
+
attempts.append(_attempt("web_search", provider, status, start, error_type=data.get("error_type", ""), error=data.get("error", "")))
|
|
1555
|
+
elif provider == "tavily":
|
|
1556
|
+
results = await call_tavily_search(query, count)
|
|
1557
|
+
sources = _normalize_source_results(results, "tavily")
|
|
1558
|
+
if sources:
|
|
1559
|
+
attempts.append(_attempt("web_search", provider, "ok", start, result_count=len(sources)))
|
|
1560
|
+
return sources, attempts
|
|
1561
|
+
attempts.append(_attempt("web_search", provider, "empty", start))
|
|
1562
|
+
elif provider == "firecrawl":
|
|
1563
|
+
results = await call_firecrawl_search(query, count)
|
|
1564
|
+
sources = _normalize_source_results(results, "firecrawl")
|
|
1565
|
+
if sources:
|
|
1566
|
+
attempts.append(_attempt("web_search", provider, "ok", start, result_count=len(sources)))
|
|
1567
|
+
return sources, attempts
|
|
1568
|
+
attempts.append(_attempt("web_search", provider, "empty", start))
|
|
1569
|
+
except Exception as e:
|
|
1570
|
+
attempts.append(_attempt("web_search", provider, "error", start, error_type="runtime_error", error=str(e)))
|
|
1571
|
+
return [], attempts
|
|
1572
|
+
|
|
1573
|
+
|
|
1574
|
+
async def _run_bilingual_web_search(
|
|
1575
|
+
query: str,
|
|
1576
|
+
count: int = 5,
|
|
1577
|
+
providers: str = "auto",
|
|
1578
|
+
fallback: str = "auto",
|
|
1579
|
+
) -> tuple[list[dict], list[dict]]:
|
|
1580
|
+
all_sources: list[dict] = []
|
|
1581
|
+
all_attempts: list[dict] = []
|
|
1582
|
+
for variant in _bilingual_search_queries(query):
|
|
1583
|
+
sources, attempts = await _run_web_search_fallback(
|
|
1584
|
+
variant["query"],
|
|
1585
|
+
count=count,
|
|
1586
|
+
providers=providers,
|
|
1587
|
+
fallback=fallback,
|
|
1588
|
+
)
|
|
1589
|
+
for source in sources:
|
|
1590
|
+
source.setdefault("query_locale", variant["locale"])
|
|
1591
|
+
all_sources = merge_sources(all_sources, sources)
|
|
1592
|
+
all_attempts.extend(attempts)
|
|
1593
|
+
return all_sources, all_attempts
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
async def _run_docs_search_fallback(
|
|
1597
|
+
query: str,
|
|
1598
|
+
providers: str = "auto",
|
|
1599
|
+
fallback: str = "auto",
|
|
1600
|
+
) -> tuple[list[dict], list[dict]]:
|
|
1601
|
+
provider_filter = _parse_provider_filter(providers)
|
|
1602
|
+
attempts: list[dict] = []
|
|
1603
|
+
configured: list[str] = []
|
|
1604
|
+
if config.context7_api_key:
|
|
1605
|
+
configured.append("context7")
|
|
1606
|
+
if config.exa_api_key:
|
|
1607
|
+
configured.append("exa")
|
|
1608
|
+
if provider_filter is not None:
|
|
1609
|
+
configured = [p for p in configured if p in provider_filter]
|
|
1610
|
+
if fallback == "off":
|
|
1611
|
+
configured = configured[:1]
|
|
1612
|
+
|
|
1613
|
+
for provider in configured:
|
|
1614
|
+
start = time.time()
|
|
1615
|
+
try:
|
|
1616
|
+
if provider == "exa":
|
|
1617
|
+
data = await exa_search(query, num_results=5, include_highlights=True)
|
|
1618
|
+
if data.get("ok"):
|
|
1619
|
+
sources = _normalize_source_results(data.get("results"), "exa")
|
|
1620
|
+
if sources:
|
|
1621
|
+
attempts.append(_attempt("docs_search", provider, "ok", start, result_count=len(sources)))
|
|
1622
|
+
return sources, attempts
|
|
1623
|
+
status = "error" if data.get("error_type") in {"auth_error", "parameter_error", "rate_limited", "timeout", "network_error", "runtime_error"} else "empty"
|
|
1624
|
+
attempts.append(_attempt("docs_search", provider, status, start, error_type=data.get("error_type", ""), error=data.get("error", "")))
|
|
1625
|
+
elif provider == "context7":
|
|
1626
|
+
data = await context7_library(query, query)
|
|
1627
|
+
if data.get("ok"):
|
|
1628
|
+
sources = [
|
|
1629
|
+
{
|
|
1630
|
+
"url": f"context7:{item.get('id')}",
|
|
1631
|
+
"title": item.get("title") or item.get("id") or "Context7",
|
|
1632
|
+
"description": item.get("description") or "",
|
|
1633
|
+
"provider": "context7",
|
|
1634
|
+
}
|
|
1635
|
+
for item in data.get("results", [])
|
|
1636
|
+
if item.get("id")
|
|
1637
|
+
]
|
|
1638
|
+
if sources:
|
|
1639
|
+
attempts.append(_attempt("docs_search", provider, "ok", start, result_count=len(sources)))
|
|
1640
|
+
return sources, attempts
|
|
1641
|
+
status = "error" if data.get("error_type") in {"auth_error", "timeout", "network_error", "runtime_error"} else "empty"
|
|
1642
|
+
attempts.append(_attempt("docs_search", provider, status, start, error_type=data.get("error_type", ""), error=data.get("error", "")))
|
|
1643
|
+
except Exception as e:
|
|
1644
|
+
attempts.append(_attempt("docs_search", provider, "error", start, error_type="runtime_error", error=str(e)))
|
|
1645
|
+
return [], attempts
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
async def call_tavily_extract(url: str) -> str | None:
|
|
1649
|
+
api_key = config.tavily_api_key
|
|
1650
|
+
if not api_key:
|
|
1651
|
+
return None
|
|
1652
|
+
endpoint = f"{config.tavily_api_url.rstrip('/')}/extract"
|
|
1653
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
1654
|
+
body = {"urls": [url], "format": "markdown"}
|
|
1655
|
+
try:
|
|
1656
|
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
1657
|
+
response = await client.post(endpoint, headers=headers, json=body)
|
|
1658
|
+
response.raise_for_status()
|
|
1659
|
+
data = response.json()
|
|
1660
|
+
if data.get("results") and len(data["results"]) > 0:
|
|
1661
|
+
content = data["results"][0].get("raw_content", "")
|
|
1662
|
+
return content if content and content.strip() else None
|
|
1663
|
+
return None
|
|
1664
|
+
except Exception:
|
|
1665
|
+
return None
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
async def call_tavily_search(query: str, max_results: int = 6) -> list[dict] | None:
|
|
1669
|
+
api_key = config.tavily_api_key
|
|
1670
|
+
if not api_key:
|
|
1671
|
+
return None
|
|
1672
|
+
endpoint = f"{config.tavily_api_url.rstrip('/')}/search"
|
|
1673
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
1674
|
+
body = {
|
|
1675
|
+
"query": query,
|
|
1676
|
+
"max_results": max_results,
|
|
1677
|
+
"search_depth": "advanced",
|
|
1678
|
+
"include_raw_content": False,
|
|
1679
|
+
"include_answer": False,
|
|
1680
|
+
}
|
|
1681
|
+
try:
|
|
1682
|
+
async with httpx.AsyncClient(timeout=90.0) as client:
|
|
1683
|
+
response = await client.post(endpoint, headers=headers, json=body)
|
|
1684
|
+
response.raise_for_status()
|
|
1685
|
+
data = response.json()
|
|
1686
|
+
results = data.get("results", [])
|
|
1687
|
+
return [
|
|
1688
|
+
{
|
|
1689
|
+
"title": r.get("title", ""),
|
|
1690
|
+
"url": r.get("url", ""),
|
|
1691
|
+
"content": r.get("content", ""),
|
|
1692
|
+
"score": r.get("score", 0),
|
|
1693
|
+
}
|
|
1694
|
+
for r in results
|
|
1695
|
+
] if results else None
|
|
1696
|
+
except Exception:
|
|
1697
|
+
return None
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
async def call_firecrawl_search(query: str, limit: int = 14) -> list[dict] | None:
|
|
1701
|
+
api_key = config.firecrawl_api_key
|
|
1702
|
+
if not api_key:
|
|
1703
|
+
return None
|
|
1704
|
+
endpoint = f"{config.firecrawl_api_url.rstrip('/')}/search"
|
|
1705
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
1706
|
+
body = {"query": query, "limit": limit}
|
|
1707
|
+
try:
|
|
1708
|
+
async with httpx.AsyncClient(timeout=90.0) as client:
|
|
1709
|
+
response = await client.post(endpoint, headers=headers, json=body)
|
|
1710
|
+
response.raise_for_status()
|
|
1711
|
+
data = response.json()
|
|
1712
|
+
results = data.get("data", {}).get("web", [])
|
|
1713
|
+
return [
|
|
1714
|
+
{
|
|
1715
|
+
"title": r.get("title", ""),
|
|
1716
|
+
"url": r.get("url", ""),
|
|
1717
|
+
"description": r.get("description", ""),
|
|
1718
|
+
}
|
|
1719
|
+
for r in results
|
|
1720
|
+
] if results else None
|
|
1721
|
+
except Exception:
|
|
1722
|
+
return None
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
async def call_firecrawl_scrape(url: str, ctx=None) -> str | None:
|
|
1726
|
+
api_key = config.firecrawl_api_key
|
|
1727
|
+
if not api_key:
|
|
1728
|
+
return None
|
|
1729
|
+
endpoint = f"{config.firecrawl_api_url.rstrip('/')}/scrape"
|
|
1730
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
1731
|
+
for attempt in range(config.retry_max_attempts):
|
|
1732
|
+
body = {
|
|
1733
|
+
"url": url,
|
|
1734
|
+
"formats": ["markdown"],
|
|
1735
|
+
"timeout": 60000,
|
|
1736
|
+
"waitFor": (attempt + 1) * 1500,
|
|
1737
|
+
}
|
|
1738
|
+
try:
|
|
1739
|
+
async with httpx.AsyncClient(timeout=90.0) as client:
|
|
1740
|
+
response = await client.post(endpoint, headers=headers, json=body)
|
|
1741
|
+
response.raise_for_status()
|
|
1742
|
+
data = response.json()
|
|
1743
|
+
markdown = data.get("data", {}).get("markdown", "")
|
|
1744
|
+
if markdown and markdown.strip():
|
|
1745
|
+
return markdown
|
|
1746
|
+
await log_info(ctx, f"Firecrawl: markdown为空, 重试 {attempt + 1}/{config.retry_max_attempts}", config.debug_enabled)
|
|
1747
|
+
except Exception as e:
|
|
1748
|
+
await log_info(ctx, f"Firecrawl error: {e}", config.debug_enabled)
|
|
1749
|
+
return None
|
|
1750
|
+
return None
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
async def call_jina_reader(url: str) -> dict[str, Any]:
|
|
1754
|
+
raw = await JinaReaderProvider(
|
|
1755
|
+
config.jina_reader_api_url,
|
|
1756
|
+
config.jina_api_key,
|
|
1757
|
+
config.jina_respond_with,
|
|
1758
|
+
config.jina_timeout,
|
|
1759
|
+
).fetch(url)
|
|
1760
|
+
return await _decode_provider_json(raw, provider="jina")
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
async def call_tavily_map(
|
|
1764
|
+
url: str,
|
|
1765
|
+
instructions: str = "",
|
|
1766
|
+
max_depth: int = 1,
|
|
1767
|
+
max_breadth: int = 20,
|
|
1768
|
+
limit: int = 50,
|
|
1769
|
+
timeout: int = 150,
|
|
1770
|
+
) -> dict[str, Any]:
|
|
1771
|
+
api_key = config.tavily_api_key
|
|
1772
|
+
if not api_key:
|
|
1773
|
+
return {
|
|
1774
|
+
"ok": False,
|
|
1775
|
+
"error_type": "config_error",
|
|
1776
|
+
"error": "TAVILY_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set TAVILY_API_KEY <key>`。",
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
endpoint = f"{config.tavily_api_url.rstrip('/')}/map"
|
|
1780
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
1781
|
+
body = {"url": url, "max_depth": max_depth, "max_breadth": max_breadth, "limit": limit, "timeout": timeout}
|
|
1782
|
+
if instructions:
|
|
1783
|
+
body["instructions"] = instructions
|
|
1784
|
+
try:
|
|
1785
|
+
async with httpx.AsyncClient(timeout=float(timeout + 10)) as client:
|
|
1786
|
+
response = await client.post(endpoint, headers=headers, json=body)
|
|
1787
|
+
response.raise_for_status()
|
|
1788
|
+
data = response.json()
|
|
1789
|
+
return {
|
|
1790
|
+
"ok": True,
|
|
1791
|
+
"base_url": data.get("base_url", ""),
|
|
1792
|
+
"results": data.get("results", []),
|
|
1793
|
+
"response_time": data.get("response_time", 0),
|
|
1794
|
+
}
|
|
1795
|
+
except httpx.TimeoutException:
|
|
1796
|
+
return {"ok": False, "error_type": "network_error", "error": f"映射超时: 请求超过{timeout}秒"}
|
|
1797
|
+
except httpx.HTTPStatusError as e:
|
|
1798
|
+
return {"ok": False, "error_type": "network_error", "error": f"HTTP错误: {e.response.status_code} - {e.response.text[:200]}"}
|
|
1799
|
+
except Exception as e:
|
|
1800
|
+
return {"ok": False, "error_type": "network_error", "error": f"映射错误: {str(e)}"}
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
async def search(
|
|
1804
|
+
query: str,
|
|
1805
|
+
platform: str = "",
|
|
1806
|
+
model: str = "",
|
|
1807
|
+
extra_sources: int = 0,
|
|
1808
|
+
validation: str = "",
|
|
1809
|
+
fallback: str = "",
|
|
1810
|
+
providers: str = "auto",
|
|
1811
|
+
stream: bool | None = None,
|
|
1812
|
+
) -> dict[str, Any]:
|
|
1813
|
+
start = time.time()
|
|
1814
|
+
session_id = new_session_id()
|
|
1815
|
+
try:
|
|
1816
|
+
validation_level = (validation or config.validation_level).strip().lower()
|
|
1817
|
+
fallback_mode = (fallback or config.fallback_mode).strip().lower()
|
|
1818
|
+
if validation_level not in config._ALLOWED_VALIDATION_LEVELS:
|
|
1819
|
+
raise ValueError(f"Invalid validation level: {validation_level}")
|
|
1820
|
+
if fallback_mode not in config._ALLOWED_FALLBACK_MODES:
|
|
1821
|
+
raise ValueError(f"Invalid fallback mode: {fallback_mode}")
|
|
1822
|
+
except ValueError as e:
|
|
1823
|
+
return _empty_search_result(start, session_id, query, "parameter_error", str(e))
|
|
1824
|
+
|
|
1825
|
+
minimum = validate_minimum_profile()
|
|
1826
|
+
if not minimum.get("ok"):
|
|
1827
|
+
return _empty_search_result(
|
|
1828
|
+
start,
|
|
1829
|
+
session_id,
|
|
1830
|
+
query,
|
|
1831
|
+
minimum.get("error_type", "config_error"),
|
|
1832
|
+
minimum.get("error", MINIMUM_PROFILE_ERROR),
|
|
1833
|
+
extra={
|
|
1834
|
+
"capability_status": minimum.get("capability_status", {}),
|
|
1835
|
+
"minimum_profile_ok": False,
|
|
1836
|
+
"validation_level": validation_level,
|
|
1837
|
+
},
|
|
1838
|
+
)
|
|
1839
|
+
|
|
1840
|
+
try:
|
|
1841
|
+
main_provider_configs = _main_search_provider_configs(model_override=model, providers=providers)
|
|
1842
|
+
except ValueError as e:
|
|
1843
|
+
return _empty_search_result(start, session_id, query, "parameter_error", str(e), extra={"validation_level": validation_level})
|
|
1844
|
+
|
|
1845
|
+
if not main_provider_configs:
|
|
1846
|
+
return _empty_search_result(
|
|
1847
|
+
start,
|
|
1848
|
+
session_id,
|
|
1849
|
+
query,
|
|
1850
|
+
"config_error",
|
|
1851
|
+
"No configured main_search provider matches --providers.",
|
|
1852
|
+
extra={
|
|
1853
|
+
"validation_level": validation_level,
|
|
1854
|
+
"capability_status": minimum.get("capability_status", {}),
|
|
1855
|
+
"minimum_profile_ok": minimum.get("ok", False),
|
|
1856
|
+
},
|
|
1857
|
+
)
|
|
1858
|
+
|
|
1859
|
+
primary_api_mode = main_provider_configs[0]["mode"]
|
|
1860
|
+
if stream is not None:
|
|
1861
|
+
for provider_config in main_provider_configs:
|
|
1862
|
+
if provider_config["provider"] == "openai-compatible":
|
|
1863
|
+
provider_config["stream"] = stream
|
|
1864
|
+
|
|
1865
|
+
has_tavily = bool(config.tavily_api_key)
|
|
1866
|
+
has_firecrawl = bool(config.firecrawl_api_key)
|
|
1867
|
+
tavily_count = 0
|
|
1868
|
+
firecrawl_count = 0
|
|
1869
|
+
if extra_sources > 0:
|
|
1870
|
+
if has_tavily and has_firecrawl:
|
|
1871
|
+
tavily_count = max(1, round(extra_sources * 0.6))
|
|
1872
|
+
firecrawl_count = extra_sources - tavily_count
|
|
1873
|
+
elif has_tavily:
|
|
1874
|
+
tavily_count = extra_sources
|
|
1875
|
+
elif has_firecrawl:
|
|
1876
|
+
firecrawl_count = extra_sources
|
|
1877
|
+
|
|
1878
|
+
docs_intent = _is_docs_intent(query)
|
|
1879
|
+
zh_current_intent = _is_zh_current_intent(query)
|
|
1880
|
+
bilingual_web_search = True
|
|
1881
|
+
web_current_intent = zh_current_intent
|
|
1882
|
+
fetch_urls = _extract_urls(query)
|
|
1883
|
+
fetch_intent = bool(fetch_urls) or _is_fetch_intent(query)
|
|
1884
|
+
supplemental_paths: list[str] = []
|
|
1885
|
+
if docs_intent:
|
|
1886
|
+
supplemental_paths.append("docs_search")
|
|
1887
|
+
if bilingual_web_search:
|
|
1888
|
+
supplemental_paths.append("web_search")
|
|
1889
|
+
if fetch_intent:
|
|
1890
|
+
supplemental_paths.append("web_fetch")
|
|
1891
|
+
selected_main_provider_configs = main_provider_configs if fallback_mode != "off" else main_provider_configs[:1]
|
|
1892
|
+
routing_decision = {
|
|
1893
|
+
"docs_intent": docs_intent,
|
|
1894
|
+
"zh_current_intent": zh_current_intent,
|
|
1895
|
+
"web_current_intent": web_current_intent,
|
|
1896
|
+
"bilingual_web_search": bilingual_web_search,
|
|
1897
|
+
"bilingual_query_locales": [item["locale"] for item in _bilingual_search_queries(query)],
|
|
1898
|
+
"fetch_intent": fetch_intent,
|
|
1899
|
+
"supplemental_paths": supplemental_paths,
|
|
1900
|
+
"validation_level": validation_level,
|
|
1901
|
+
"fallback_mode": fallback_mode,
|
|
1902
|
+
"providers": providers,
|
|
1903
|
+
"main_search_chain": [item["provider"] for item in selected_main_provider_configs],
|
|
1904
|
+
"openai_compatible_stream": next((bool(item.get("stream")) for item in selected_main_provider_configs if item["provider"] == "openai-compatible"), False),
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
provider_attempts: list[dict] = []
|
|
1908
|
+
main_providers = _main_search_providers(main_provider_configs, fallback_mode)
|
|
1909
|
+
primary_start = time.time()
|
|
1910
|
+
primary_result = None
|
|
1911
|
+
successful_main_config: dict[str, Any] | None = None
|
|
1912
|
+
last_primary_error: dict[str, Any] | None = None
|
|
1913
|
+
for provider_config, search_provider in zip(selected_main_provider_configs, main_providers):
|
|
1914
|
+
primary_start = time.time()
|
|
1915
|
+
try:
|
|
1916
|
+
candidate_result = await search_provider.search(query, platform)
|
|
1917
|
+
if candidate_result:
|
|
1918
|
+
primary_result = candidate_result
|
|
1919
|
+
successful_main_config = provider_config
|
|
1920
|
+
provider_attempts.append(_attempt("main_search", search_provider.get_provider_name(), "ok", primary_start, result_count=1))
|
|
1921
|
+
break
|
|
1922
|
+
last_primary_error = _primary_search_error_result(
|
|
1923
|
+
start,
|
|
1924
|
+
session_id,
|
|
1925
|
+
query,
|
|
1926
|
+
provider_config["mode"],
|
|
1927
|
+
"network_error",
|
|
1928
|
+
f"{search_provider.get_provider_name()} 返回空结果",
|
|
1929
|
+
)
|
|
1930
|
+
provider_attempts.append(_attempt("main_search", search_provider.get_provider_name(), "empty", primary_start))
|
|
1931
|
+
except Exception as e:
|
|
1932
|
+
error_result = _primary_search_exception_result(start, session_id, query, provider_config["mode"], search_provider.get_provider_name(), e)
|
|
1933
|
+
last_primary_error = error_result
|
|
1934
|
+
provider_attempts.append(
|
|
1935
|
+
_attempt(
|
|
1936
|
+
"main_search",
|
|
1937
|
+
search_provider.get_provider_name(),
|
|
1938
|
+
"error",
|
|
1939
|
+
primary_start,
|
|
1940
|
+
error_type=error_result["error_type"],
|
|
1941
|
+
error=error_result["error"],
|
|
1942
|
+
)
|
|
1943
|
+
)
|
|
1944
|
+
if primary_result is None:
|
|
1945
|
+
result = last_primary_error or _primary_search_error_result(start, session_id, query, primary_api_mode, "network_error", "搜索失败或无结果")
|
|
1946
|
+
result["provider_attempts"] = provider_attempts
|
|
1947
|
+
result["providers_used"] = _provider_names_from_attempts(provider_attempts)
|
|
1948
|
+
result["fallback_used"] = _fallback_used(provider_attempts)
|
|
1949
|
+
result["routing_decision"] = routing_decision
|
|
1950
|
+
result["validation_level"] = validation_level
|
|
1951
|
+
result["minimum_profile_ok"] = minimum.get("ok", False)
|
|
1952
|
+
result["capability_status"] = minimum.get("capability_status", {})
|
|
1953
|
+
return result
|
|
1954
|
+
|
|
1955
|
+
successful_main_config = successful_main_config or selected_main_provider_configs[0]
|
|
1956
|
+
primary_api_mode = successful_main_config["mode"]
|
|
1957
|
+
effective_model = successful_main_config["model"]
|
|
1958
|
+
|
|
1959
|
+
coros: list[Any] = []
|
|
1960
|
+
if tavily_count:
|
|
1961
|
+
coros.append(call_tavily_search(query, tavily_count))
|
|
1962
|
+
if firecrawl_count:
|
|
1963
|
+
coros.append(call_firecrawl_search(query, firecrawl_count))
|
|
1964
|
+
|
|
1965
|
+
gathered = await asyncio.gather(*coros, return_exceptions=True)
|
|
1966
|
+
primary_result = primary_result or ""
|
|
1967
|
+
tavily_results: list[dict] | None = None
|
|
1968
|
+
firecrawl_results: list[dict] | None = None
|
|
1969
|
+
idx = 0
|
|
1970
|
+
if tavily_count:
|
|
1971
|
+
tavily_results = None if isinstance(gathered[idx], BaseException) else gathered[idx]
|
|
1972
|
+
idx += 1
|
|
1973
|
+
if firecrawl_count:
|
|
1974
|
+
firecrawl_results = None if isinstance(gathered[idx], BaseException) else gathered[idx]
|
|
1975
|
+
|
|
1976
|
+
answer, primary_sources = split_answer_and_sources(primary_result)
|
|
1977
|
+
extra_source_items = extra_results_to_sources(tavily_results, firecrawl_results)
|
|
1978
|
+
for item_provider, results in (("tavily", tavily_results), ("firecrawl", firecrawl_results)):
|
|
1979
|
+
if results:
|
|
1980
|
+
provider_attempts.append(_attempt("web_search", item_provider, "ok", start, result_count=len(results)))
|
|
1981
|
+
|
|
1982
|
+
supplemental_sources: list[dict] = []
|
|
1983
|
+
if validation_level in {"balanced", "strict"}:
|
|
1984
|
+
if docs_intent:
|
|
1985
|
+
docs_sources, docs_attempts = await _run_docs_search_fallback(query, providers=providers, fallback=fallback_mode)
|
|
1986
|
+
provider_attempts.extend(docs_attempts)
|
|
1987
|
+
supplemental_sources.extend(docs_sources)
|
|
1988
|
+
if bilingual_web_search:
|
|
1989
|
+
web_sources, web_attempts = await _run_bilingual_web_search(query, count=max(1, extra_sources or 3), providers=providers, fallback=fallback_mode)
|
|
1990
|
+
provider_attempts.extend(web_attempts)
|
|
1991
|
+
supplemental_sources.extend(web_sources)
|
|
1992
|
+
if fetch_intent:
|
|
1993
|
+
fetch_url = fetch_urls[0] if fetch_urls else query.strip()
|
|
1994
|
+
fetch_result, fetch_attempts = await _run_web_fetch_fallback(fetch_url, fallback=fallback_mode)
|
|
1995
|
+
provider_attempts.extend(fetch_attempts)
|
|
1996
|
+
if fetch_result:
|
|
1997
|
+
supplemental_sources.append({"url": fetch_result["url"], "provider": fetch_result["provider"], "description": fetch_result["content"][:300]})
|
|
1998
|
+
|
|
1999
|
+
extra_source_items = merge_sources(extra_source_items, supplemental_sources)
|
|
2000
|
+
sources = merge_sources(primary_sources, extra_source_items)
|
|
2001
|
+
ok = bool(answer or sources)
|
|
2002
|
+
if validation_level == "strict" and not sources:
|
|
2003
|
+
ok = False
|
|
2004
|
+
return {
|
|
2005
|
+
"ok": ok,
|
|
2006
|
+
"error_type": "" if ok else ("evidence_error" if validation_level == "strict" else "network_error"),
|
|
2007
|
+
"error": "" if ok else ("strict 模式证据不足" if validation_level == "strict" else "搜索失败或无结果"),
|
|
2008
|
+
"session_id": session_id,
|
|
2009
|
+
"query": query,
|
|
2010
|
+
"platform": platform,
|
|
2011
|
+
"model": effective_model,
|
|
2012
|
+
"primary_api_mode": primary_api_mode,
|
|
2013
|
+
"content": answer,
|
|
2014
|
+
"sources": sources,
|
|
2015
|
+
"sources_count": len(sources),
|
|
2016
|
+
"primary_sources": primary_sources,
|
|
2017
|
+
"primary_sources_count": len(primary_sources),
|
|
2018
|
+
"extra_sources": extra_source_items,
|
|
2019
|
+
"extra_sources_count": len(extra_source_items),
|
|
2020
|
+
"source_warning": SOURCE_PROVENANCE_WARNING if extra_source_items else "",
|
|
2021
|
+
"routing_decision": routing_decision,
|
|
2022
|
+
"providers_used": _provider_names_from_attempts(provider_attempts),
|
|
2023
|
+
"provider_attempts": provider_attempts,
|
|
2024
|
+
"fallback_used": _fallback_used(provider_attempts),
|
|
2025
|
+
"validation_level": validation_level,
|
|
2026
|
+
"minimum_profile_ok": minimum.get("ok", False),
|
|
2027
|
+
"capability_status": minimum.get("capability_status", {}),
|
|
2028
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
def _primary_search_exception_result(
|
|
2033
|
+
start: float,
|
|
2034
|
+
session_id: str,
|
|
2035
|
+
query: str,
|
|
2036
|
+
primary_api_mode: str,
|
|
2037
|
+
provider_name: str,
|
|
2038
|
+
exc: BaseException,
|
|
2039
|
+
) -> dict[str, Any]:
|
|
2040
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
2041
|
+
return _primary_search_error_result(
|
|
2042
|
+
start,
|
|
2043
|
+
session_id,
|
|
2044
|
+
query,
|
|
2045
|
+
primary_api_mode,
|
|
2046
|
+
"network_error",
|
|
2047
|
+
f"{provider_name} 请求超时: {str(exc)}",
|
|
2048
|
+
)
|
|
2049
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
2050
|
+
body = exc.response.text[:300] if exc.response is not None else str(exc)
|
|
2051
|
+
status = exc.response.status_code if exc.response is not None else "unknown"
|
|
2052
|
+
return _primary_search_error_result(
|
|
2053
|
+
start,
|
|
2054
|
+
session_id,
|
|
2055
|
+
query,
|
|
2056
|
+
primary_api_mode,
|
|
2057
|
+
"network_error",
|
|
2058
|
+
f"{provider_name} HTTP {status}: {body}",
|
|
2059
|
+
)
|
|
2060
|
+
if isinstance(exc, httpx.RequestError):
|
|
2061
|
+
return _primary_search_error_result(
|
|
2062
|
+
start,
|
|
2063
|
+
session_id,
|
|
2064
|
+
query,
|
|
2065
|
+
primary_api_mode,
|
|
2066
|
+
"network_error",
|
|
2067
|
+
f"{provider_name} 网络错误: {str(exc)}",
|
|
2068
|
+
)
|
|
2069
|
+
return _primary_search_error_result(
|
|
2070
|
+
start,
|
|
2071
|
+
session_id,
|
|
2072
|
+
query,
|
|
2073
|
+
primary_api_mode,
|
|
2074
|
+
"runtime_error",
|
|
2075
|
+
f"{provider_name} 运行错误: {str(exc)}",
|
|
2076
|
+
)
|
|
2077
|
+
|
|
2078
|
+
|
|
2079
|
+
def _primary_search_error_result(
|
|
2080
|
+
start: float,
|
|
2081
|
+
session_id: str,
|
|
2082
|
+
query: str,
|
|
2083
|
+
primary_api_mode: str,
|
|
2084
|
+
error_type: str,
|
|
2085
|
+
error: str,
|
|
2086
|
+
) -> dict[str, Any]:
|
|
2087
|
+
return {
|
|
2088
|
+
"ok": False,
|
|
2089
|
+
"error_type": error_type,
|
|
2090
|
+
"error": error,
|
|
2091
|
+
"session_id": session_id,
|
|
2092
|
+
"query": query,
|
|
2093
|
+
"primary_api_mode": primary_api_mode,
|
|
2094
|
+
"content": "",
|
|
2095
|
+
"sources": [],
|
|
2096
|
+
"sources_count": 0,
|
|
2097
|
+
"primary_sources": [],
|
|
2098
|
+
"primary_sources_count": 0,
|
|
2099
|
+
"extra_sources": [],
|
|
2100
|
+
"extra_sources_count": 0,
|
|
2101
|
+
"source_warning": "",
|
|
2102
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
async def fetch(url: str) -> dict[str, Any]:
|
|
2107
|
+
start = time.time()
|
|
2108
|
+
fetch_result, attempts = await _run_web_fetch_fallback(url)
|
|
2109
|
+
if fetch_result:
|
|
2110
|
+
return {
|
|
2111
|
+
**fetch_result,
|
|
2112
|
+
"provider_attempts": attempts,
|
|
2113
|
+
"fallback_used": _fallback_used(attempts),
|
|
2114
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
if not (config.tavily_api_key or config.jina_api_key or config.firecrawl_api_key):
|
|
2118
|
+
error = "TAVILY_API_KEY、JINA_API_KEY 和 FIRECRAWL_API_KEY 均未配置"
|
|
2119
|
+
error_type = "config_error"
|
|
2120
|
+
else:
|
|
2121
|
+
error = "所有提取服务均未能获取内容"
|
|
2122
|
+
error_type = "network_error"
|
|
2123
|
+
return {
|
|
2124
|
+
"ok": False,
|
|
2125
|
+
"url": url,
|
|
2126
|
+
"provider": "",
|
|
2127
|
+
"content": "",
|
|
2128
|
+
"error_type": error_type,
|
|
2129
|
+
"error": error,
|
|
2130
|
+
"provider_attempts": attempts,
|
|
2131
|
+
"fallback_used": _fallback_used(attempts),
|
|
2132
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
async def map_site(
|
|
2137
|
+
url: str,
|
|
2138
|
+
instructions: str = "",
|
|
2139
|
+
max_depth: int = 1,
|
|
2140
|
+
max_breadth: int = 20,
|
|
2141
|
+
limit: int = 50,
|
|
2142
|
+
timeout: int = 150,
|
|
2143
|
+
) -> dict[str, Any]:
|
|
2144
|
+
start = time.time()
|
|
2145
|
+
result = await call_tavily_map(url, instructions, max_depth, max_breadth, limit, timeout)
|
|
2146
|
+
result.setdefault("url", url)
|
|
2147
|
+
result.setdefault("elapsed_ms", _elapsed_ms(start))
|
|
2148
|
+
return result
|
|
2149
|
+
|
|
2150
|
+
|
|
2151
|
+
async def exa_search(
|
|
2152
|
+
query: str,
|
|
2153
|
+
num_results: int = 5,
|
|
2154
|
+
search_type: str = "neural",
|
|
2155
|
+
include_text: bool = False,
|
|
2156
|
+
include_highlights: bool = False,
|
|
2157
|
+
start_published_date: str = "",
|
|
2158
|
+
include_domains: str | list[str] | tuple[str, ...] = "",
|
|
2159
|
+
exclude_domains: str | list[str] | tuple[str, ...] = "",
|
|
2160
|
+
category: str = "",
|
|
2161
|
+
) -> dict[str, Any]:
|
|
2162
|
+
api_key = config.exa_api_key
|
|
2163
|
+
if not api_key:
|
|
2164
|
+
return {
|
|
2165
|
+
"ok": False,
|
|
2166
|
+
"error_type": "config_error",
|
|
2167
|
+
"error": "EXA_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set EXA_API_KEY <key>`。",
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
provider = ExaSearchProvider(config.exa_base_url, api_key, config.exa_timeout)
|
|
2171
|
+
include_domain_list = _normalize_domain_filter(include_domains)
|
|
2172
|
+
exclude_domain_list = _normalize_domain_filter(exclude_domains)
|
|
2173
|
+
|
|
2174
|
+
raw = await provider.search(
|
|
2175
|
+
query=query,
|
|
2176
|
+
num_results=num_results,
|
|
2177
|
+
search_type=search_type,
|
|
2178
|
+
include_text=include_text,
|
|
2179
|
+
include_highlights=include_highlights,
|
|
2180
|
+
start_published_date=start_published_date or None,
|
|
2181
|
+
include_domains=include_domain_list,
|
|
2182
|
+
exclude_domains=exclude_domain_list,
|
|
2183
|
+
category=category or None,
|
|
2184
|
+
)
|
|
2185
|
+
try:
|
|
2186
|
+
data = json.loads(raw)
|
|
2187
|
+
except json.JSONDecodeError:
|
|
2188
|
+
return {"ok": False, "error_type": "parse_error", "error": raw}
|
|
2189
|
+
if not data.get("ok", False):
|
|
2190
|
+
data.setdefault("error_type", "network_error")
|
|
2191
|
+
return data
|
|
2192
|
+
|
|
2193
|
+
|
|
2194
|
+
async def _decode_provider_json(raw: str, provider: str = "jina") -> dict[str, Any]:
|
|
2195
|
+
try:
|
|
2196
|
+
return json.loads(raw)
|
|
2197
|
+
except json.JSONDecodeError:
|
|
2198
|
+
return {"ok": False, "provider": provider, "error_type": "parse_error", "error": raw}
|
|
2199
|
+
|
|
2200
|
+
|
|
2201
|
+
async def jina_fetch(url: str) -> dict[str, Any]:
|
|
2202
|
+
return await call_jina_reader(url)
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
async def exa_find_similar(url: str, num_results: int = 5) -> dict[str, Any]:
|
|
2206
|
+
api_key = config.exa_api_key
|
|
2207
|
+
if not api_key:
|
|
2208
|
+
return {
|
|
2209
|
+
"ok": False,
|
|
2210
|
+
"error_type": "config_error",
|
|
2211
|
+
"error": "EXA_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set EXA_API_KEY <key>`。",
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
provider = ExaSearchProvider(config.exa_base_url, api_key, config.exa_timeout)
|
|
2215
|
+
raw = await provider.find_similar(url=url, num_results=num_results)
|
|
2216
|
+
try:
|
|
2217
|
+
data = json.loads(raw)
|
|
2218
|
+
except json.JSONDecodeError:
|
|
2219
|
+
return {"ok": False, "error_type": "parse_error", "error": raw}
|
|
2220
|
+
if not data.get("ok", False):
|
|
2221
|
+
data.setdefault("error_type", "network_error")
|
|
2222
|
+
return data
|
|
2223
|
+
|
|
2224
|
+
|
|
2225
|
+
async def zhipu_search(
|
|
2226
|
+
query: str,
|
|
2227
|
+
count: int = 10,
|
|
2228
|
+
search_engine: str = "",
|
|
2229
|
+
search_recency_filter: str = "noLimit",
|
|
2230
|
+
search_domain_filter: str = "",
|
|
2231
|
+
content_size: str = "medium",
|
|
2232
|
+
) -> dict[str, Any]:
|
|
2233
|
+
api_key = config.zhipu_api_key
|
|
2234
|
+
if not api_key:
|
|
2235
|
+
return {
|
|
2236
|
+
"ok": False,
|
|
2237
|
+
"error_type": "config_error",
|
|
2238
|
+
"error": "ZHIPU_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set ZHIPU_API_KEY <key>`。",
|
|
2239
|
+
}
|
|
2240
|
+
provider = ZhipuWebSearchProvider(
|
|
2241
|
+
config.zhipu_api_url,
|
|
2242
|
+
api_key,
|
|
2243
|
+
search_engine or config.zhipu_search_engine,
|
|
2244
|
+
config.zhipu_timeout,
|
|
2245
|
+
)
|
|
2246
|
+
raw = await provider.search(
|
|
2247
|
+
query=query,
|
|
2248
|
+
count=count,
|
|
2249
|
+
search_engine=search_engine or None,
|
|
2250
|
+
search_recency_filter=search_recency_filter,
|
|
2251
|
+
search_domain_filter=search_domain_filter,
|
|
2252
|
+
content_size=content_size,
|
|
2253
|
+
)
|
|
2254
|
+
try:
|
|
2255
|
+
data = json.loads(raw)
|
|
2256
|
+
except json.JSONDecodeError:
|
|
2257
|
+
return {"ok": False, "error_type": "parse_error", "error": raw}
|
|
2258
|
+
if not data.get("ok", False):
|
|
2259
|
+
data.setdefault("error_type", "network_error")
|
|
2260
|
+
return data
|
|
2261
|
+
|
|
2262
|
+
|
|
2263
|
+
async def context7_library(name: str, query: str = "") -> dict[str, Any]:
|
|
2264
|
+
api_key = config.context7_api_key
|
|
2265
|
+
if not api_key:
|
|
2266
|
+
return {
|
|
2267
|
+
"ok": False,
|
|
2268
|
+
"error_type": "config_error",
|
|
2269
|
+
"error": "CONTEXT7_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set CONTEXT7_API_KEY <key>`。",
|
|
2270
|
+
}
|
|
2271
|
+
provider = Context7Provider(config.context7_base_url, api_key, config.context7_timeout)
|
|
2272
|
+
raw = await provider.library(name, query)
|
|
2273
|
+
try:
|
|
2274
|
+
data = json.loads(raw)
|
|
2275
|
+
except json.JSONDecodeError:
|
|
2276
|
+
return {"ok": False, "error_type": "parse_error", "error": raw}
|
|
2277
|
+
if not data.get("ok", False):
|
|
2278
|
+
data.setdefault("error_type", "network_error")
|
|
2279
|
+
return data
|
|
2280
|
+
|
|
2281
|
+
|
|
2282
|
+
async def context7_docs(library_id: str, query: str) -> dict[str, Any]:
|
|
2283
|
+
api_key = config.context7_api_key
|
|
2284
|
+
if not api_key:
|
|
2285
|
+
return {
|
|
2286
|
+
"ok": False,
|
|
2287
|
+
"error_type": "config_error",
|
|
2288
|
+
"error": "CONTEXT7_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set CONTEXT7_API_KEY <key>`。",
|
|
2289
|
+
}
|
|
2290
|
+
provider = Context7Provider(config.context7_base_url, api_key, config.context7_timeout)
|
|
2291
|
+
raw = await provider.docs(library_id, query)
|
|
2292
|
+
try:
|
|
2293
|
+
data = json.loads(raw)
|
|
2294
|
+
except json.JSONDecodeError:
|
|
2295
|
+
return {"ok": False, "error_type": "parse_error", "error": raw}
|
|
2296
|
+
if not data.get("ok", False):
|
|
2297
|
+
data.setdefault("error_type", "network_error")
|
|
2298
|
+
return data
|
|
2299
|
+
|
|
2300
|
+
|
|
2301
|
+
async def _test_primary_chat_completion(api_url: str, api_key: str, model: str) -> dict[str, Any]:
|
|
2302
|
+
chat_url = f"{api_url.rstrip('/')}/chat/completions"
|
|
2303
|
+
start = time.time()
|
|
2304
|
+
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
2305
|
+
response = await client.post(
|
|
2306
|
+
chat_url,
|
|
2307
|
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "application/json, text/event-stream"},
|
|
2308
|
+
json={
|
|
2309
|
+
"model": model,
|
|
2310
|
+
"messages": [{"role": "user", "content": "Reply with exactly: ok"}],
|
|
2311
|
+
"stream": False,
|
|
2312
|
+
"max_tokens": 8,
|
|
2313
|
+
},
|
|
2314
|
+
)
|
|
2315
|
+
response_time = _elapsed_ms(start)
|
|
2316
|
+
content_type = response.headers.get("content-type", "")
|
|
2317
|
+
if response.status_code != 200:
|
|
2318
|
+
return {
|
|
2319
|
+
"status": "warning",
|
|
2320
|
+
"message": f"HTTP {response.status_code}: {response.text[:100]}",
|
|
2321
|
+
"response_time_ms": response_time,
|
|
2322
|
+
"http_status": response.status_code,
|
|
2323
|
+
"content_type": content_type,
|
|
2324
|
+
"has_content": bool(response.text.strip()),
|
|
2325
|
+
}
|
|
2326
|
+
return {
|
|
2327
|
+
"status": "ok",
|
|
2328
|
+
"message": f"聊天接口可用 (HTTP {response.status_code})",
|
|
2329
|
+
"response_time_ms": response_time,
|
|
2330
|
+
"http_status": response.status_code,
|
|
2331
|
+
"content_type": content_type,
|
|
2332
|
+
"has_content": bool(response.text.strip()),
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
|
|
2336
|
+
def _diagnose_check_result(
|
|
2337
|
+
*,
|
|
2338
|
+
name: str,
|
|
2339
|
+
status: str,
|
|
2340
|
+
message: str,
|
|
2341
|
+
start: float,
|
|
2342
|
+
http_status: int | None = None,
|
|
2343
|
+
content_type: str = "",
|
|
2344
|
+
has_content: bool = False,
|
|
2345
|
+
stream: bool | None = None,
|
|
2346
|
+
) -> dict[str, Any]:
|
|
2347
|
+
result: dict[str, Any] = {
|
|
2348
|
+
"name": name,
|
|
2349
|
+
"status": status,
|
|
2350
|
+
"message": message,
|
|
2351
|
+
"response_time_ms": _elapsed_ms(start),
|
|
2352
|
+
"has_content": has_content,
|
|
2353
|
+
}
|
|
2354
|
+
if http_status is not None:
|
|
2355
|
+
result["http_status"] = http_status
|
|
2356
|
+
if content_type:
|
|
2357
|
+
result["content_type"] = content_type
|
|
2358
|
+
if stream is not None:
|
|
2359
|
+
result["stream"] = stream
|
|
2360
|
+
return result
|
|
2361
|
+
|
|
2362
|
+
|
|
2363
|
+
def _openai_compatible_diagnosis(quick: dict[str, Any], no_stream: dict[str, Any], stream: dict[str, Any]) -> tuple[bool, str, str]:
|
|
2364
|
+
quick_ok = quick.get("status") == "ok"
|
|
2365
|
+
no_stream_ok = no_stream.get("status") == "ok"
|
|
2366
|
+
stream_ok = stream.get("status") == "ok"
|
|
2367
|
+
search_timeout = no_stream.get("status") == "timeout" or stream.get("status") == "timeout"
|
|
2368
|
+
|
|
2369
|
+
if no_stream_ok and stream_ok:
|
|
2370
|
+
return (
|
|
2371
|
+
True,
|
|
2372
|
+
"OpenAI-compatible 主链路正常。",
|
|
2373
|
+
"真实 search 形态的 stream=false 和 stream=true 都能返回。若用户仍卡住,更可能是调用方、PATH、超时设置或上游偶发波动。",
|
|
2374
|
+
)
|
|
2375
|
+
if stream_ok and not no_stream_ok:
|
|
2376
|
+
return (
|
|
2377
|
+
False,
|
|
2378
|
+
"非流式请求不稳定,流式请求可用。",
|
|
2379
|
+
"建议设置 `OPENAI_COMPATIBLE_STREAM=true`,或临时使用 `smart-search search ... --stream`。",
|
|
2380
|
+
)
|
|
2381
|
+
if no_stream_ok and not stream_ok:
|
|
2382
|
+
return (
|
|
2383
|
+
False,
|
|
2384
|
+
"流式请求不稳定,非流式请求可用。",
|
|
2385
|
+
"建议设置 `OPENAI_COMPATIBLE_STREAM=false`,或临时使用 `smart-search search ... --no-stream`。",
|
|
2386
|
+
)
|
|
2387
|
+
if quick_ok and search_timeout:
|
|
2388
|
+
return (
|
|
2389
|
+
False,
|
|
2390
|
+
"小请求能通,但真实 search 形态超时。",
|
|
2391
|
+
"这通常是上游模型或中转站在处理 smart-search 的完整 prompt 时卡住;建议换模型/中转,或把本诊断报告贴给维护者。",
|
|
2392
|
+
)
|
|
2393
|
+
if quick_ok:
|
|
2394
|
+
return (
|
|
2395
|
+
False,
|
|
2396
|
+
"小请求能通,但真实 search 形态失败。",
|
|
2397
|
+
"这更像上游模型/中转站对 smart-search 请求形态不兼容;建议换模型/中转,或把本诊断报告贴给维护者。",
|
|
2398
|
+
)
|
|
2399
|
+
return (
|
|
2400
|
+
False,
|
|
2401
|
+
"OpenAI-compatible 基础请求不可用。",
|
|
2402
|
+
"请先检查 API URL、API key、模型名和网络;修好后再运行本诊断命令。",
|
|
2403
|
+
)
|
|
2404
|
+
|
|
2405
|
+
|
|
2406
|
+
async def _probe_openai_compatible_search_shape(
|
|
2407
|
+
api_url: str,
|
|
2408
|
+
api_key: str,
|
|
2409
|
+
model: str,
|
|
2410
|
+
*,
|
|
2411
|
+
stream: bool,
|
|
2412
|
+
timeout_seconds: float,
|
|
2413
|
+
) -> dict[str, Any]:
|
|
2414
|
+
name = "真实 search 请求 (stream=true)" if stream else "真实 search 请求 (stream=false)"
|
|
2415
|
+
start = time.time()
|
|
2416
|
+
payload = {
|
|
2417
|
+
"model": model,
|
|
2418
|
+
"messages": [
|
|
2419
|
+
{"role": "system", "content": search_prompt},
|
|
2420
|
+
{"role": "user", "content": get_local_time_info() + "\nping"},
|
|
2421
|
+
],
|
|
2422
|
+
"stream": stream,
|
|
2423
|
+
}
|
|
2424
|
+
headers = {
|
|
2425
|
+
"Authorization": f"Bearer {api_key}",
|
|
2426
|
+
"Content-Type": "application/json",
|
|
2427
|
+
"Accept": "application/json, text/event-stream",
|
|
2428
|
+
"User-Agent": "smart-search/diagnose",
|
|
2429
|
+
}
|
|
2430
|
+
timeout = httpx.Timeout(connect=6.0, read=timeout_seconds, write=10.0, pool=None)
|
|
2431
|
+
try:
|
|
2432
|
+
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, verify=config.ssl_verify_enabled) as client:
|
|
2433
|
+
if stream:
|
|
2434
|
+
async with client.stream(
|
|
2435
|
+
"POST",
|
|
2436
|
+
f"{api_url.rstrip('/')}/chat/completions",
|
|
2437
|
+
headers=headers,
|
|
2438
|
+
json=payload,
|
|
2439
|
+
) as response:
|
|
2440
|
+
content_type = response.headers.get("content-type", "")
|
|
2441
|
+
response.raise_for_status()
|
|
2442
|
+
has_content = False
|
|
2443
|
+
async for line in response.aiter_lines():
|
|
2444
|
+
stripped = line.strip()
|
|
2445
|
+
if not stripped:
|
|
2446
|
+
continue
|
|
2447
|
+
if not stripped.startswith("data:"):
|
|
2448
|
+
continue
|
|
2449
|
+
if stripped in ("data: [DONE]", "data:[DONE]"):
|
|
2450
|
+
continue
|
|
2451
|
+
try:
|
|
2452
|
+
data = json.loads(stripped[5:].lstrip())
|
|
2453
|
+
except json.JSONDecodeError:
|
|
2454
|
+
continue
|
|
2455
|
+
choices = data.get("choices", []) if isinstance(data, dict) else []
|
|
2456
|
+
if not choices:
|
|
2457
|
+
continue
|
|
2458
|
+
delta = choices[0].get("delta", {})
|
|
2459
|
+
if isinstance(delta, dict) and str(delta.get("content") or "").strip():
|
|
2460
|
+
has_content = True
|
|
2461
|
+
break
|
|
2462
|
+
message = choices[0].get("message", {})
|
|
2463
|
+
if isinstance(message, dict) and str(message.get("content") or "").strip():
|
|
2464
|
+
has_content = True
|
|
2465
|
+
break
|
|
2466
|
+
status = "ok" if has_content else "empty"
|
|
2467
|
+
message = f"HTTP {response.status_code}; {'收到流式内容' if has_content else '未收到内容'}"
|
|
2468
|
+
return _diagnose_check_result(
|
|
2469
|
+
name=name,
|
|
2470
|
+
status=status,
|
|
2471
|
+
message=message,
|
|
2472
|
+
start=start,
|
|
2473
|
+
http_status=response.status_code,
|
|
2474
|
+
content_type=content_type,
|
|
2475
|
+
has_content=has_content,
|
|
2476
|
+
stream=stream,
|
|
2477
|
+
)
|
|
2478
|
+
|
|
2479
|
+
response = await client.post(
|
|
2480
|
+
f"{api_url.rstrip('/')}/chat/completions",
|
|
2481
|
+
headers=headers,
|
|
2482
|
+
json=payload,
|
|
2483
|
+
)
|
|
2484
|
+
content_type = response.headers.get("content-type", "")
|
|
2485
|
+
response.raise_for_status()
|
|
2486
|
+
content = await OpenAICompatibleSearchProvider(api_url, api_key, model, stream=False)._parse_completion_response(response)
|
|
2487
|
+
has_content = bool(content.strip())
|
|
2488
|
+
status = "ok" if has_content else "empty"
|
|
2489
|
+
message = f"HTTP {response.status_code}; {'收到内容' if has_content else '返回为空'}"
|
|
2490
|
+
return _diagnose_check_result(
|
|
2491
|
+
name=name,
|
|
2492
|
+
status=status,
|
|
2493
|
+
message=message,
|
|
2494
|
+
start=start,
|
|
2495
|
+
http_status=response.status_code,
|
|
2496
|
+
content_type=content_type,
|
|
2497
|
+
has_content=has_content,
|
|
2498
|
+
stream=stream,
|
|
2499
|
+
)
|
|
2500
|
+
except httpx.TimeoutException as e:
|
|
2501
|
+
return _diagnose_check_result(name=name, status="timeout", message=f"请求超时: {e}", start=start, stream=stream)
|
|
2502
|
+
except httpx.HTTPStatusError as e:
|
|
2503
|
+
body = e.response.text[:200] if e.response is not None else str(e)
|
|
2504
|
+
status_code = e.response.status_code if e.response is not None else None
|
|
2505
|
+
content_type = e.response.headers.get("content-type", "") if e.response is not None else ""
|
|
2506
|
+
return _diagnose_check_result(
|
|
2507
|
+
name=name,
|
|
2508
|
+
status="warning",
|
|
2509
|
+
message=f"HTTP {status_code}: {body}",
|
|
2510
|
+
start=start,
|
|
2511
|
+
http_status=status_code,
|
|
2512
|
+
content_type=content_type,
|
|
2513
|
+
stream=stream,
|
|
2514
|
+
)
|
|
2515
|
+
except httpx.RequestError as e:
|
|
2516
|
+
return _diagnose_check_result(name=name, status="error", message=f"网络错误: {e}", start=start, stream=stream)
|
|
2517
|
+
except Exception as e:
|
|
2518
|
+
return _diagnose_check_result(name=name, status="error", message=f"运行错误: {e}", start=start, stream=stream)
|
|
2519
|
+
|
|
2520
|
+
|
|
2521
|
+
async def diagnose_openai_compatible(timeout_seconds: float = 30.0) -> dict[str, Any]:
|
|
2522
|
+
start = time.time()
|
|
2523
|
+
api_url = config.openai_compatible_api_url
|
|
2524
|
+
api_key = config.openai_compatible_api_key
|
|
2525
|
+
model = config.openai_compatible_model
|
|
2526
|
+
info = config.config_path_info()
|
|
2527
|
+
result: dict[str, Any] = {
|
|
2528
|
+
"ok": False,
|
|
2529
|
+
"provider": "openai-compatible",
|
|
2530
|
+
"api_url": api_url or "未配置",
|
|
2531
|
+
"api_key": config._mask_api_key(api_key) if api_key else "未配置",
|
|
2532
|
+
"model": model,
|
|
2533
|
+
"configured_stream": config.openai_compatible_stream,
|
|
2534
|
+
"timeout_seconds": timeout_seconds,
|
|
2535
|
+
"config_file": info.get("config_file", ""),
|
|
2536
|
+
"config_dir_source": info.get("config_dir_source", ""),
|
|
2537
|
+
"checks": [],
|
|
2538
|
+
"next_command": OPENAI_COMPATIBLE_DIAGNOSE_COMMAND,
|
|
2539
|
+
}
|
|
2540
|
+
missing = []
|
|
2541
|
+
if not api_url:
|
|
2542
|
+
missing.append("OPENAI_COMPATIBLE_API_URL")
|
|
2543
|
+
if not api_key:
|
|
2544
|
+
missing.append("OPENAI_COMPATIBLE_API_KEY")
|
|
2545
|
+
if missing:
|
|
2546
|
+
result.update(
|
|
2547
|
+
{
|
|
2548
|
+
"error_type": "config_error",
|
|
2549
|
+
"error": "缺少 OpenAI-compatible 配置: " + ", ".join(missing),
|
|
2550
|
+
"summary": "OpenAI-compatible 配置不完整。",
|
|
2551
|
+
"recommendation": "请先运行 `smart-search setup`,或用 `smart-search config set` 填好缺失项。",
|
|
2552
|
+
"missing": missing,
|
|
2553
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
2554
|
+
}
|
|
2555
|
+
)
|
|
2556
|
+
return result
|
|
2557
|
+
|
|
2558
|
+
try:
|
|
2559
|
+
quick = await _test_primary_chat_completion(api_url, api_key, model)
|
|
2560
|
+
except httpx.TimeoutException as e:
|
|
2561
|
+
quick = {"status": "timeout", "message": f"轻量 chat 请求超时: {e}"}
|
|
2562
|
+
except httpx.RequestError as e:
|
|
2563
|
+
quick = {"status": "error", "message": f"轻量 chat 网络错误: {e}"}
|
|
2564
|
+
except Exception as e:
|
|
2565
|
+
quick = {"status": "error", "message": f"轻量 chat 运行错误: {e}"}
|
|
2566
|
+
quick_check = {
|
|
2567
|
+
"name": "轻量 chat 请求",
|
|
2568
|
+
"status": quick.get("status", "error"),
|
|
2569
|
+
"message": quick.get("message", ""),
|
|
2570
|
+
"response_time_ms": quick.get("response_time_ms"),
|
|
2571
|
+
"http_status": quick.get("http_status"),
|
|
2572
|
+
"content_type": quick.get("content_type", ""),
|
|
2573
|
+
"has_content": bool(quick.get("has_content", quick.get("status") == "ok")),
|
|
2574
|
+
}
|
|
2575
|
+
result["checks"].append(quick_check)
|
|
2576
|
+
no_stream = await _probe_openai_compatible_search_shape(api_url, api_key, model, stream=False, timeout_seconds=timeout_seconds)
|
|
2577
|
+
result["checks"].append(no_stream)
|
|
2578
|
+
stream = await _probe_openai_compatible_search_shape(api_url, api_key, model, stream=True, timeout_seconds=timeout_seconds)
|
|
2579
|
+
result["checks"].append(stream)
|
|
2580
|
+
|
|
2581
|
+
ok, summary, recommendation = _openai_compatible_diagnosis(quick_check, no_stream, stream)
|
|
2582
|
+
result.update(
|
|
2583
|
+
{
|
|
2584
|
+
"ok": ok,
|
|
2585
|
+
"error_type": "" if ok else "network_error",
|
|
2586
|
+
"error": "" if ok else summary,
|
|
2587
|
+
"summary": summary,
|
|
2588
|
+
"recommendation": recommendation,
|
|
2589
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
2590
|
+
}
|
|
2591
|
+
)
|
|
2592
|
+
return result
|
|
2593
|
+
|
|
2594
|
+
|
|
2595
|
+
async def _test_primary_connection(api_url: str, api_key: str, model: str) -> dict[str, Any]:
|
|
2596
|
+
chat_test = await _test_primary_chat_completion(api_url, api_key, model)
|
|
2597
|
+
|
|
2598
|
+
models_url = f"{api_url.rstrip('/')}/models"
|
|
2599
|
+
start = time.time()
|
|
2600
|
+
try:
|
|
2601
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
2602
|
+
response = await client.get(
|
|
2603
|
+
models_url,
|
|
2604
|
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
|
2605
|
+
)
|
|
2606
|
+
response_time = _elapsed_ms(start)
|
|
2607
|
+
if response.status_code != 200:
|
|
2608
|
+
models_test = {"status": "warning", "message": f"HTTP {response.status_code}: {response.text[:100]}", "response_time_ms": response_time}
|
|
2609
|
+
else:
|
|
2610
|
+
models_test = {"status": "ok", "message": f"成功获取模型列表 (HTTP {response.status_code})", "response_time_ms": response_time}
|
|
2611
|
+
try:
|
|
2612
|
+
models_data = response.json()
|
|
2613
|
+
model_names = [m["id"] for m in models_data.get("data", []) if isinstance(m, dict) and "id" in m]
|
|
2614
|
+
models_test["message"] += f",共 {len(model_names)} 个模型"
|
|
2615
|
+
if model_names:
|
|
2616
|
+
models_test["available_models"] = model_names
|
|
2617
|
+
except Exception:
|
|
2618
|
+
pass
|
|
2619
|
+
except httpx.HTTPError as e:
|
|
2620
|
+
models_test = {"status": "warning", "message": f"模型列表接口请求失败: {e}", "response_time_ms": _elapsed_ms(start)}
|
|
2621
|
+
|
|
2622
|
+
if chat_test.get("status") != "ok":
|
|
2623
|
+
models_state = "可用" if models_test.get("status") == "ok" else "不可用"
|
|
2624
|
+
return {
|
|
2625
|
+
"status": "warning",
|
|
2626
|
+
"message": f"聊天接口不可用: {chat_test.get('message', '')};模型列表接口{models_state}: {models_test['message']}",
|
|
2627
|
+
"response_time_ms": chat_test.get("response_time_ms", models_test.get("response_time_ms")),
|
|
2628
|
+
"models_endpoint_test": models_test,
|
|
2629
|
+
"chat_completion_test": chat_test,
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
if models_test.get("status") != "ok":
|
|
2633
|
+
return {
|
|
2634
|
+
"status": "ok",
|
|
2635
|
+
"message": f"{chat_test['message']};模型列表接口不可用: {models_test['message']}",
|
|
2636
|
+
"response_time_ms": chat_test.get("response_time_ms"),
|
|
2637
|
+
"models_endpoint_test": models_test,
|
|
2638
|
+
"chat_completion_test": chat_test,
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
result: dict[str, Any] = {
|
|
2642
|
+
"status": "ok",
|
|
2643
|
+
"message": f"{chat_test['message']};{models_test['message']}",
|
|
2644
|
+
"response_time_ms": chat_test.get("response_time_ms"),
|
|
2645
|
+
"models_endpoint_test": models_test,
|
|
2646
|
+
"chat_completion_test": chat_test,
|
|
2647
|
+
}
|
|
2648
|
+
if "available_models" in models_test:
|
|
2649
|
+
result["available_models"] = models_test["available_models"]
|
|
2650
|
+
return result
|
|
2651
|
+
|
|
2652
|
+
|
|
2653
|
+
async def _test_main_provider_connection(provider_config: dict[str, Any]) -> dict[str, Any]:
|
|
2654
|
+
return await _test_primary_connection(provider_config["api_url"], provider_config["api_key"], provider_config["model"])
|
|
2655
|
+
|
|
2656
|
+
|
|
2657
|
+
async def _safe_test_main_provider_connection(provider_config: dict[str, Any]) -> dict[str, Any]:
|
|
2658
|
+
try:
|
|
2659
|
+
return await _test_main_provider_connection(provider_config)
|
|
2660
|
+
except httpx.TimeoutException:
|
|
2661
|
+
return {"status": "timeout", "message": f"{provider_config['provider']} 请求超时,请检查网络连接或 API URL"}
|
|
2662
|
+
except httpx.RequestError as e:
|
|
2663
|
+
return {"status": "error", "message": f"{provider_config['provider']} 网络错误: {str(e)}"}
|
|
2664
|
+
except Exception as e:
|
|
2665
|
+
return {"status": "error", "message": f"{provider_config['provider']} 未知错误: {str(e)}"}
|
|
2666
|
+
|
|
2667
|
+
|
|
2668
|
+
async def _test_exa_connection() -> dict[str, Any]:
|
|
2669
|
+
exa_key = config.exa_api_key
|
|
2670
|
+
if not exa_key:
|
|
2671
|
+
return {"status": "not_configured", "message": "EXA_API_KEY 未设置,Exa 搜索功能不可用"}
|
|
2672
|
+
start = time.time()
|
|
2673
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
2674
|
+
resp = await client.post(
|
|
2675
|
+
f"{config.exa_base_url.rstrip('/')}/search",
|
|
2676
|
+
headers={"x-api-key": exa_key, "content-type": "application/json"},
|
|
2677
|
+
json={"query": "test", "numResults": 1, "type": "keyword"},
|
|
2678
|
+
)
|
|
2679
|
+
response_time = _elapsed_ms(start)
|
|
2680
|
+
if resp.status_code == 200:
|
|
2681
|
+
return {"status": "ok", "message": "Exa API 可用 (HTTP 200)", "response_time_ms": response_time}
|
|
2682
|
+
return {"status": "warning", "message": f"HTTP {resp.status_code}: {resp.text[:100]}", "response_time_ms": response_time}
|
|
2683
|
+
|
|
2684
|
+
|
|
2685
|
+
async def _test_tavily_connection() -> dict[str, Any]:
|
|
2686
|
+
tavily_key = config.tavily_api_key
|
|
2687
|
+
if not tavily_key:
|
|
2688
|
+
return {"status": "not_configured", "message": "TAVILY_API_KEY 未设置,Tavily 功能不可用"}
|
|
2689
|
+
start = time.time()
|
|
2690
|
+
timeout = httpx.Timeout(connect=6.0, read=config.tavily_timeout, write=10.0, pool=None)
|
|
2691
|
+
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, verify=config.ssl_verify_enabled) as client:
|
|
2692
|
+
resp = await client.post(
|
|
2693
|
+
f"{config.tavily_api_url.rstrip('/')}/search",
|
|
2694
|
+
headers={"Authorization": f"Bearer {tavily_key}", "Content-Type": "application/json"},
|
|
2695
|
+
json={"query": "test", "max_results": 1, "search_depth": "basic"},
|
|
2696
|
+
)
|
|
2697
|
+
response_time = _elapsed_ms(start)
|
|
2698
|
+
if resp.status_code == 200:
|
|
2699
|
+
return {"status": "ok", "message": "Tavily API 可用 (HTTP 200)", "response_time_ms": response_time}
|
|
2700
|
+
return {"status": "warning", "message": f"HTTP {resp.status_code}: {resp.text[:100]}", "response_time_ms": response_time}
|
|
2701
|
+
|
|
2702
|
+
|
|
2703
|
+
async def _test_jina_connection() -> dict[str, Any]:
|
|
2704
|
+
if config.jina_respond_with and not config.jina_api_key:
|
|
2705
|
+
return {"status": "config_error", "message": "JINA_RESPOND_WITH requires JINA_API_KEY"}
|
|
2706
|
+
if not config.jina_api_key:
|
|
2707
|
+
return {"status": "not_configured", "message": "JINA_API_KEY 未设置,Jina 不满足 standard web_fetch;匿名 Reader 只能作为显式实验使用"}
|
|
2708
|
+
start = time.time()
|
|
2709
|
+
data = await jina_fetch("https://example.com")
|
|
2710
|
+
response_time = _elapsed_ms(start)
|
|
2711
|
+
if data.get("ok"):
|
|
2712
|
+
return {"status": "ok", "message": "Jina Reader 可用", "response_time_ms": response_time}
|
|
2713
|
+
error_type = data.get("error_type", "")
|
|
2714
|
+
status = error_type if error_type in {"auth_error", "config_error", "parameter_error", "rate_limited", "timeout"} else "warning"
|
|
2715
|
+
return {"status": status, "message": data.get("error", "Jina Reader 不可用"), "response_time_ms": response_time}
|
|
2716
|
+
|
|
2717
|
+
|
|
2718
|
+
async def _test_zhipu_connection() -> dict[str, Any]:
|
|
2719
|
+
if not config.zhipu_api_key:
|
|
2720
|
+
return {"status": "not_configured", "message": "ZHIPU_API_KEY 未设置,智谱搜索功能不可用"}
|
|
2721
|
+
result = await zhipu_search("test", count=1)
|
|
2722
|
+
if result.get("ok"):
|
|
2723
|
+
return {"status": "ok", "message": "智谱 Web Search 可用", "response_time_ms": result.get("elapsed_ms", 0)}
|
|
2724
|
+
return {"status": "warning", "message": result.get("error", "智谱 Web Search 不可用"), "response_time_ms": result.get("elapsed_ms", 0)}
|
|
2725
|
+
|
|
2726
|
+
|
|
2727
|
+
async def _test_context7_connection() -> dict[str, Any]:
|
|
2728
|
+
if not config.context7_api_key:
|
|
2729
|
+
return {"status": "not_configured", "message": "CONTEXT7_API_KEY 未设置,Context7 功能不可用"}
|
|
2730
|
+
result = await context7_library("react", "hooks")
|
|
2731
|
+
if result.get("ok"):
|
|
2732
|
+
return {"status": "ok", "message": "Context7 API 可用", "response_time_ms": result.get("elapsed_ms", 0)}
|
|
2733
|
+
return {"status": "warning", "message": result.get("error", "Context7 API 不可用"), "response_time_ms": result.get("elapsed_ms", 0)}
|
|
2734
|
+
|
|
2735
|
+
|
|
2736
|
+
async def doctor() -> dict[str, Any]:
|
|
2737
|
+
info = config.get_config_info()
|
|
2738
|
+
|
|
2739
|
+
main_provider_configs: list[dict[str, Any]] = []
|
|
2740
|
+
try:
|
|
2741
|
+
main_provider_configs = _main_search_provider_configs()
|
|
2742
|
+
info["main_search_connection_tests"] = {}
|
|
2743
|
+
for provider_config in main_provider_configs:
|
|
2744
|
+
info["main_search_connection_tests"][provider_config["provider"]] = await _safe_test_main_provider_connection(provider_config)
|
|
2745
|
+
if main_provider_configs:
|
|
2746
|
+
first_provider = main_provider_configs[0]
|
|
2747
|
+
info["primary_api_mode"] = first_provider["mode"]
|
|
2748
|
+
info["primary_connection_test"] = info["main_search_connection_tests"][first_provider["provider"]]
|
|
2749
|
+
else:
|
|
2750
|
+
info["primary_connection_test"] = {"status": "config_error", "message": MINIMUM_PROFILE_ERROR}
|
|
2751
|
+
except ValueError as e:
|
|
2752
|
+
info["main_search_connection_tests"] = {}
|
|
2753
|
+
info["primary_connection_test"] = {"status": "config_error", "message": str(e)}
|
|
2754
|
+
except Exception as e:
|
|
2755
|
+
info["main_search_connection_tests"] = {}
|
|
2756
|
+
info["primary_connection_test"] = {"status": "error", "message": f"未知错误: {str(e)}"}
|
|
2757
|
+
|
|
2758
|
+
try:
|
|
2759
|
+
info["exa_connection_test"] = await _test_exa_connection()
|
|
2760
|
+
except httpx.TimeoutException:
|
|
2761
|
+
info["exa_connection_test"] = {"status": "timeout", "message": "Exa API 请求超时"}
|
|
2762
|
+
except Exception as e:
|
|
2763
|
+
info["exa_connection_test"] = {"status": "error", "message": str(e)}
|
|
2764
|
+
|
|
2765
|
+
try:
|
|
2766
|
+
info["tavily_connection_test"] = await _test_tavily_connection()
|
|
2767
|
+
except httpx.TimeoutException:
|
|
2768
|
+
info["tavily_connection_test"] = {"status": "timeout", "message": "Tavily API 请求超时"}
|
|
2769
|
+
except Exception as e:
|
|
2770
|
+
info["tavily_connection_test"] = {"status": "error", "message": str(e)}
|
|
2771
|
+
|
|
2772
|
+
try:
|
|
2773
|
+
info["jina_connection_test"] = await _test_jina_connection()
|
|
2774
|
+
except httpx.TimeoutException:
|
|
2775
|
+
info["jina_connection_test"] = {"status": "timeout", "message": "Jina Reader 请求超时"}
|
|
2776
|
+
except Exception as e:
|
|
2777
|
+
info["jina_connection_test"] = {"status": "error", "message": str(e)}
|
|
2778
|
+
|
|
2779
|
+
if config.firecrawl_api_key:
|
|
2780
|
+
info["firecrawl_connection_test"] = {"status": "configured", "message": "FIRECRAWL_API_KEY 已设置"}
|
|
2781
|
+
else:
|
|
2782
|
+
info["firecrawl_connection_test"] = {"status": "not_configured", "message": "FIRECRAWL_API_KEY 未设置,Firecrawl 功能不可用"}
|
|
2783
|
+
|
|
2784
|
+
try:
|
|
2785
|
+
info["zhipu_connection_test"] = await _test_zhipu_connection()
|
|
2786
|
+
except httpx.TimeoutException:
|
|
2787
|
+
info["zhipu_connection_test"] = {"status": "timeout", "message": "智谱 API 请求超时"}
|
|
2788
|
+
except Exception as e:
|
|
2789
|
+
info["zhipu_connection_test"] = {"status": "error", "message": str(e)}
|
|
2790
|
+
|
|
2791
|
+
try:
|
|
2792
|
+
info["context7_connection_test"] = await _test_context7_connection()
|
|
2793
|
+
except httpx.TimeoutException:
|
|
2794
|
+
info["context7_connection_test"] = {"status": "timeout", "message": "Context7 API 请求超时"}
|
|
2795
|
+
except Exception as e:
|
|
2796
|
+
info["context7_connection_test"] = {"status": "error", "message": str(e)}
|
|
2797
|
+
|
|
2798
|
+
minimum = validate_minimum_profile()
|
|
2799
|
+
info["capability_status"] = minimum.get("capability_status", get_capability_status())
|
|
2800
|
+
info["minimum_profile_ok"] = minimum.get("ok", False)
|
|
2801
|
+
info["minimum_profile_missing"] = minimum.get("missing", [])
|
|
2802
|
+
main_connection_tests = info.get("main_search_connection_tests") or {}
|
|
2803
|
+
main_search_statuses = [item.get("status") for item in main_connection_tests.values() if isinstance(item, dict)]
|
|
2804
|
+
primary_test = info.get("primary_connection_test", {})
|
|
2805
|
+
primary_status = primary_test.get("status")
|
|
2806
|
+
main_search_ok = any(status == "ok" for status in main_search_statuses) if main_connection_tests else primary_status == "ok"
|
|
2807
|
+
info["ok"] = main_search_ok and minimum.get("ok", False)
|
|
2808
|
+
if info["ok"]:
|
|
2809
|
+
info["error_type"] = ""
|
|
2810
|
+
info["error"] = ""
|
|
2811
|
+
elif info.get("config_parameter_errors"):
|
|
2812
|
+
info["error"] = "; ".join(info["config_parameter_errors"])
|
|
2813
|
+
info["error_type"] = "parameter_error"
|
|
2814
|
+
elif not minimum.get("ok", False):
|
|
2815
|
+
info["error"] = minimum.get("error", MINIMUM_PROFILE_ERROR)
|
|
2816
|
+
info["error_type"] = minimum.get("error_type", "config_error")
|
|
2817
|
+
else:
|
|
2818
|
+
info["error"] = primary_test.get("message", "Primary connection check failed")
|
|
2819
|
+
if primary_status == "config_error":
|
|
2820
|
+
info["error_type"] = "config_error"
|
|
2821
|
+
elif primary_status in {"timeout", "error", "warning"}:
|
|
2822
|
+
info["error_type"] = "network_error"
|
|
2823
|
+
else:
|
|
2824
|
+
info["error_type"] = "runtime_error"
|
|
2825
|
+
return info
|
|
2826
|
+
|
|
2827
|
+
|
|
2828
|
+
def config_path() -> dict[str, Any]:
|
|
2829
|
+
return config.config_path_info()
|
|
2830
|
+
|
|
2831
|
+
|
|
2832
|
+
def config_list(show_secrets: bool = False) -> dict[str, Any]:
|
|
2833
|
+
return {
|
|
2834
|
+
"ok": True,
|
|
2835
|
+
"config_file": str(config.config_file),
|
|
2836
|
+
"values": config.get_saved_config(masked=not show_secrets),
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
|
|
2840
|
+
def config_set(key: str, value: str) -> dict[str, Any]:
|
|
2841
|
+
try:
|
|
2842
|
+
config.set_config_value(key, value)
|
|
2843
|
+
except ValueError as e:
|
|
2844
|
+
return {"ok": False, "error_type": "parameter_error", "error": str(e), "config_file": str(config.config_file)}
|
|
2845
|
+
saved = config.get_saved_config(masked=True)
|
|
2846
|
+
return {
|
|
2847
|
+
"ok": True,
|
|
2848
|
+
"config_file": str(config.config_file),
|
|
2849
|
+
"key": key.strip().upper(),
|
|
2850
|
+
"value": saved.get(key.strip().upper(), ""),
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
|
|
2854
|
+
def config_unset(key: str) -> dict[str, Any]:
|
|
2855
|
+
try:
|
|
2856
|
+
config.unset_config_value(key)
|
|
2857
|
+
except ValueError as e:
|
|
2858
|
+
return {"ok": False, "error_type": "parameter_error", "error": str(e), "config_file": str(config.config_file), "key": key.strip().upper()}
|
|
2859
|
+
return {"ok": True, "config_file": str(config.config_file), "key": key.strip().upper()}
|
|
2860
|
+
|
|
2861
|
+
|
|
2862
|
+
def write_output(path: str | Path, content: str) -> None:
|
|
2863
|
+
target = Path(path)
|
|
2864
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
2865
|
+
target.write_text(content, encoding="utf-8")
|