fruxon 0.13.2__tar.gz → 0.13.3__tar.gz

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.
Files changed (149) hide show
  1. {fruxon-0.13.2 → fruxon-0.13.3}/HISTORY.md +16 -0
  2. {fruxon-0.13.2 → fruxon-0.13.3}/PKG-INFO +2 -1
  3. {fruxon-0.13.2 → fruxon-0.13.3}/README.md +1 -0
  4. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/_version.py +2 -2
  5. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/__init__.py +1 -0
  6. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/agents_executions.py +1 -0
  7. fruxon-0.13.3/src/fruxon/cli/knowledge_bases.py +266 -0
  8. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/fruxon.py +70 -0
  9. fruxon-0.13.3/tests/test_knowledge_bases.py +278 -0
  10. {fruxon-0.13.2 → fruxon-0.13.3}/.gitignore +0 -0
  11. {fruxon-0.13.2 → fruxon-0.13.3}/LICENSE +0 -0
  12. {fruxon-0.13.2 → fruxon-0.13.3}/pyproject.toml +0 -0
  13. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/__init__.py +0 -0
  14. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/__main__.py +0 -0
  15. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/_ssl.py +0 -0
  16. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/_crash.py +0 -0
  17. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/_schema.py +0 -0
  18. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/_shared.py +0 -0
  19. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/_stream.py +0 -0
  20. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/agents.py +0 -0
  21. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/agents_approvals.py +0 -0
  22. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/agents_budget.py +0 -0
  23. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/agents_channels.py +0 -0
  24. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/agents_draft.py +0 -0
  25. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/agents_lifecycle.py +0 -0
  26. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/agents_memory.py +0 -0
  27. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/agents_revisions.py +0 -0
  28. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/agents_sandbox.py +0 -0
  29. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/agents_sandbox_test.py +0 -0
  30. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/agents_slots.py +0 -0
  31. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/agents_tests.py +0 -0
  32. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/agents_topics.py +0 -0
  33. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/applications.py +0 -0
  34. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/applications_entry_points.py +0 -0
  35. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/assets.py +0 -0
  36. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/auth.py +0 -0
  37. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/capabilities.py +0 -0
  38. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/completion.py +0 -0
  39. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/config.py +0 -0
  40. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/consult_pins.py +0 -0
  41. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/describe.py +0 -0
  42. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/doctor.py +0 -0
  43. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/environments.py +0 -0
  44. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/escalations.py +0 -0
  45. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/examples.py +0 -0
  46. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/guides.py +0 -0
  47. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/integrations.py +0 -0
  48. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/keys.py +0 -0
  49. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/llm_providers.py +0 -0
  50. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/messages.py +0 -0
  51. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/metrics.py +0 -0
  52. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/participants.py +0 -0
  53. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/pipelines.py +0 -0
  54. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/secrets.py +0 -0
  55. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/skills.py +0 -0
  56. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/storage.py +0 -0
  57. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/tools.py +0 -0
  58. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/triggers.py +0 -0
  59. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/triggers_ledger.py +0 -0
  60. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/triggers_questions.py +0 -0
  61. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/triggers_shape.py +0 -0
  62. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/cli/workspaces.py +0 -0
  63. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/credentials.py +0 -0
  64. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/device_auth.py +0 -0
  65. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/doctor.py +0 -0
  66. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/exceptions.py +0 -0
  67. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/flow_validation.py +0 -0
  68. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/models.py +0 -0
  69. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/output.py +0 -0
  70. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/params.py +0 -0
  71. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/sandbox_scenario.py +0 -0
  72. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/skills/__init__.py +0 -0
  73. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/skills/fruxon-agent-mode/SKILL.md +0 -0
  74. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/skills/fruxon-build-agent/SKILL.md +0 -0
  75. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/skills/fruxon-create-integration/SKILL.md +0 -0
  76. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/skills/fruxon-debug-trace/SKILL.md +0 -0
  77. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/skills/fruxon-meet/SKILL.md +0 -0
  78. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/skills/fruxon-use-integrations/SKILL.md +0 -0
  79. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/telemetry.py +0 -0
  80. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/ui.py +0 -0
  81. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/update_check.py +0 -0
  82. {fruxon-0.13.2 → fruxon-0.13.3}/src/fruxon/validation.py +0 -0
  83. {fruxon-0.13.2 → fruxon-0.13.3}/tests/__init__.py +0 -0
  84. {fruxon-0.13.2 → fruxon-0.13.3}/tests/conftest.py +0 -0
  85. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_actor.py +0 -0
  86. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_agent_lifecycle.py +0 -0
  87. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_agent_slots.py +0 -0
  88. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_agents_check_cli.py +0 -0
  89. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_agents_revisions_cli.py +0 -0
  90. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_agents_sandbox.py +0 -0
  91. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_applications.py +0 -0
  92. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_approvals.py +0 -0
  93. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_assets.py +0 -0
  94. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_budgets.py +0 -0
  95. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_capabilities.py +0 -0
  96. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_channels.py +0 -0
  97. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_cli.py +0 -0
  98. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_cli_base_url_routing.py +0 -0
  99. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_cli_crash.py +0 -0
  100. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_client.py +0 -0
  101. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_connect_nudge.py +0 -0
  102. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_consult.py +0 -0
  103. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_credentials.py +0 -0
  104. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_describe.py +0 -0
  105. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_device_auth.py +0 -0
  106. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_doctor.py +0 -0
  107. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_draft_bootstrap.py +0 -0
  108. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_draft_evaluate_cli.py +0 -0
  109. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_draft_pull_shape.py +0 -0
  110. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_draft_validate_cli.py +0 -0
  111. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_drafts.py +0 -0
  112. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_entry_points.py +0 -0
  113. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_environments.py +0 -0
  114. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_escalations.py +0 -0
  115. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_execution_records.py +0 -0
  116. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_flow_validation.py +0 -0
  117. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_fruxon.py +0 -0
  118. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_guides.py +0 -0
  119. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_integration_triggers_cli.py +0 -0
  120. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_integrations_authorize_cli.py +0 -0
  121. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_kb_trial_cli.py +0 -0
  122. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_llm_provider_configs_cli.py +0 -0
  123. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_memory.py +0 -0
  124. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_messages.py +0 -0
  125. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_metrics.py +0 -0
  126. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_online_secret_refs.py +0 -0
  127. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_output.py +0 -0
  128. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_params.py +0 -0
  129. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_participants.py +0 -0
  130. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_participants_write.py +0 -0
  131. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_pipelines.py +0 -0
  132. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_sandbox_scenario.py +0 -0
  133. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_schema.py +0 -0
  134. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_secrets.py +0 -0
  135. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_skills.py +0 -0
  136. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_ssl.py +0 -0
  137. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_storage.py +0 -0
  138. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_telemetry.py +0 -0
  139. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_test_chats.py +0 -0
  140. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_topics.py +0 -0
  141. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_triggers.py +0 -0
  142. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_triggers_ledger.py +0 -0
  143. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_triggers_questions.py +0 -0
  144. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_triggers_shape.py +0 -0
  145. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_triggers_write.py +0 -0
  146. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_ui.py +0 -0
  147. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_update_check.py +0 -0
  148. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_validation.py +0 -0
  149. {fruxon-0.13.2 → fruxon-0.13.3}/tests/test_workspaces_cli.py +0 -0
@@ -1,5 +1,21 @@
1
1
  # History
2
2
 
3
+ ## Unreleased — A knowledge base can be consolidated
4
+
5
+ Serial merge-per-conversation cannot repair a pair once both halves exist:
6
+ each was written before the other was searchable. The backend now runs a
7
+ pass over the whole corpus (fruxon-backend, `knowledgeBases/{kb}:consolidate`).
8
+
9
+ - **New `fruxon knowledge-bases consolidate <kb>`** — embeds the base's
10
+ live documents, clusters those above a similarity threshold, and prints
11
+ the clusters. Report-only unless `--apply`, which has the base's
12
+ consolidator agent decide each cluster and merge it (survivor keeps the
13
+ union of tags, labels and source conversations; the rest are archived).
14
+ Waits for the report by default; `--no-wait` prints the queued run and
15
+ `fruxon knowledge-bases consolidation <kb> <run>` reads it later.
16
+ - `agents executions list --trigger-type` accepts `KNOWLEDGE_CONSOLIDATION`,
17
+ the trigger the consolidator agent's runs carry.
18
+
3
19
  ## Unreleased — What a listing leaves out, and what a typo returns
4
20
 
5
21
  A patch release from a CLI trial run against a live workspace. Four defects,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fruxon
3
- Version: 0.13.2
3
+ Version: 0.13.3
4
4
  Summary: The Fruxon SDK is a lightweight Python client for integrating with the Fruxon platform.
5
5
  Project-URL: bugs, https://github.com/fruxon-ai/fruxon-sdk/issues
6
6
  Project-URL: changelog, https://github.com/fruxon-ai/fruxon-sdk/blob/main/HISTORY.md
@@ -138,6 +138,7 @@ Integrations + tools:
138
138
  | `fruxon keys list/mint/revoke/delete/history/scopes` | Audit and revoke scoped tokens. Minting opens the dashboard so the secret never enters the CLI process. |
139
139
  | `fruxon llm-providers list/get/models` | Browse LLM providers and models supported at the tenant level. |
140
140
  | `fruxon assets create/list/get/wait/operations/delete` | Manage knowledge-base (RAG) assets a step can query — upload local files, wait for async ingestion, inspect operations, and use the ids for `assetConfig.assetIds`. `delete` confirms + refuses without `--yes` in agent mode. |
141
+ | `fruxon knowledge-bases consolidate/consolidation` | Find the near-duplicate articles a per-conversation merge left in an editable knowledge base and, with `--apply`, have the base's consolidator agent fold each cluster into one (union of tags, labels and sources; the rest archived with a `merged_into` label). Report-only by default; waits for the report unless `--no-wait`. |
141
142
  | `fruxon assets search/documents/chunks` | Reproduce and inspect a retrieval. `search` runs the same query an agent's `search_assets` does (`--mode HYBRID/VECTOR/FULL_TEXT`, `--top-k`, fusion weights) — note `score` is on a different scale per mode, and in hybrid the top hit is always 1.0. `documents` lists what the index contains (a `chunkCount` of 0 means that file yielded no indexable text); `chunks` reads one document's chunks in order. The `documentId` these print is the **index's** id, which is what `chunks` takes — on a knowledge base's backing asset it is not the editable document's id, and it changes whenever a document's body is re-indexed. The `fileName` (`<knowledge document id>.md`) is the document a `knowledge_base` tool would edit. |
142
143
  | `fruxon metrics list` | Browse the evaluation-metric catalog — the ids a step's LLM-judge config (`judge.metrics[]`) binds, with a default weight each. |
143
144
  | `fruxon triggers list/get/create/update/delete/fire/bind/unbind` | Manage triggers — the schedule/event sources that fire agents. `create`/`update` take `--file` (+`--schema`); `create` also takes `--application`, the Application that will own the trigger and must own everything it fires. `bind`/`unbind` wire which agents fire; `fire` runs it now. `fire`/`delete`/`unbind` confirm + refuse without `--yes` in agent mode. The control plane for autonomy. `get` returns the full stored `workShape` and every `scheduleTimes` slot, so `get -o json | jq .workShape` produces a body `update --file` can post straight back — a PATCH replaces the whole shape, so edit what you read. |
@@ -105,6 +105,7 @@ Integrations + tools:
105
105
  | `fruxon keys list/mint/revoke/delete/history/scopes` | Audit and revoke scoped tokens. Minting opens the dashboard so the secret never enters the CLI process. |
106
106
  | `fruxon llm-providers list/get/models` | Browse LLM providers and models supported at the tenant level. |
107
107
  | `fruxon assets create/list/get/wait/operations/delete` | Manage knowledge-base (RAG) assets a step can query — upload local files, wait for async ingestion, inspect operations, and use the ids for `assetConfig.assetIds`. `delete` confirms + refuses without `--yes` in agent mode. |
108
+ | `fruxon knowledge-bases consolidate/consolidation` | Find the near-duplicate articles a per-conversation merge left in an editable knowledge base and, with `--apply`, have the base's consolidator agent fold each cluster into one (union of tags, labels and sources; the rest archived with a `merged_into` label). Report-only by default; waits for the report unless `--no-wait`. |
108
109
  | `fruxon assets search/documents/chunks` | Reproduce and inspect a retrieval. `search` runs the same query an agent's `search_assets` does (`--mode HYBRID/VECTOR/FULL_TEXT`, `--top-k`, fusion weights) — note `score` is on a different scale per mode, and in hybrid the top hit is always 1.0. `documents` lists what the index contains (a `chunkCount` of 0 means that file yielded no indexable text); `chunks` reads one document's chunks in order. The `documentId` these print is the **index's** id, which is what `chunks` takes — on a knowledge base's backing asset it is not the editable document's id, and it changes whenever a document's body is re-indexed. The `fileName` (`<knowledge document id>.md`) is the document a `knowledge_base` tool would edit. |
109
110
  | `fruxon metrics list` | Browse the evaluation-metric catalog — the ids a step's LLM-judge config (`judge.metrics[]`) binds, with a default weight each. |
110
111
  | `fruxon triggers list/get/create/update/delete/fire/bind/unbind` | Manage triggers — the schedule/event sources that fire agents. `create`/`update` take `--file` (+`--schema`); `create` also takes `--application`, the Application that will own the trigger and must own everything it fires. `bind`/`unbind` wire which agents fire; `fire` runs it now. `fire`/`delete`/`unbind` confirm + refuse without `--yes` in agent mode. The control plane for autonomy. `get` returns the full stored `workShape` and every `scheduleTimes` slot, so `get -o json | jq .workShape` produces a body `update --file` can post straight back — a PATCH replaces the whole shape, so edit what you read. |
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.13.2'
22
- __version_tuple__ = version_tuple = (0, 13, 2)
21
+ __version__ = version = '0.13.3'
22
+ __version_tuple__ = version_tuple = (0, 13, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -599,6 +599,7 @@ from fruxon.cli import examples as _examples # noqa: E402, F401
599
599
  from fruxon.cli import guides as _guides # noqa: E402, F401
600
600
  from fruxon.cli import integrations as _integrations # noqa: E402, F401
601
601
  from fruxon.cli import keys as _keys # noqa: E402, F401
602
+ from fruxon.cli import knowledge_bases as _knowledge_bases # noqa: E402, F401
602
603
  from fruxon.cli import llm_providers as _llm_providers # noqa: E402, F401
603
604
  from fruxon.cli import messages as _messages # noqa: E402, F401
604
605
  from fruxon.cli import metrics as _metrics # noqa: E402, F401
@@ -81,6 +81,7 @@ _VALID_TRIGGER_TYPE = frozenset(
81
81
  "PARTICIPANT_REQUEST",
82
82
  "RELEVANCE_CHECK",
83
83
  "DOCUMENT_ENRICHMENT",
84
+ "KNOWLEDGE_CONSOLIDATION",
84
85
  "COLLECTION_ITEM",
85
86
  "TEST",
86
87
  "TOPIC_TURN",
@@ -0,0 +1,266 @@
1
+ """``fruxon knowledge-bases`` — passes over an editable knowledge base.
2
+
3
+ A *knowledge base* is the authored, reviewable corpus behind a
4
+ ``manual_knowledge`` asset; its documents reach search only when the base
5
+ is published. This group carries the corpus-level operations that no
6
+ single document verb can express. Today that is ``consolidate``: find the
7
+ near-duplicate articles a per-item merge stage left behind and, on
8
+ ``--apply``, have the base's consolidator agent fold each cluster into one.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Annotated
15
+
16
+ import typer
17
+
18
+ from fruxon.cli import app
19
+ from fruxon.cli._shared import build_client
20
+ from fruxon.exceptions import FruxonError
21
+ from fruxon.ui import (
22
+ EXIT_VALIDATION,
23
+ fail,
24
+ fail_from_api_error,
25
+ resolve_output_format,
26
+ say_info,
27
+ say_ok,
28
+ say_warn,
29
+ stderr,
30
+ )
31
+
32
+ knowledge_bases_app = typer.Typer(
33
+ help="Passes over an editable knowledge base (consolidate near-duplicate articles).",
34
+ no_args_is_help=True,
35
+ )
36
+ app.add_typer(knowledge_bases_app, name="knowledge-bases")
37
+
38
+ _TERMINAL = {"COMPLETED", "FAILED"}
39
+
40
+
41
+ @knowledge_bases_app.command("consolidate")
42
+ def knowledge_bases_consolidate(
43
+ knowledge_base: Annotated[str, typer.Argument(help="Knowledge base id.")],
44
+ apply: Annotated[
45
+ bool,
46
+ typer.Option(
47
+ "--apply",
48
+ help=(
49
+ "Merge the clusters instead of only reporting them. Needs a consolidator agent on the "
50
+ "knowledge base; without one the pass reports whatever this says."
51
+ ),
52
+ ),
53
+ ] = False,
54
+ threshold: Annotated[
55
+ float | None,
56
+ typer.Option(
57
+ "--threshold", help="Cosine similarity at or above which two documents cluster (server default 0.86)."
58
+ ),
59
+ ] = None,
60
+ max_cluster_size: Annotated[
61
+ int | None,
62
+ typer.Option(
63
+ "--max-cluster-size", help="Largest cluster handed to the agent in one decision (server default 5)."
64
+ ),
65
+ ] = None,
66
+ recent_minutes: Annotated[
67
+ int | None,
68
+ typer.Option(
69
+ "--recent-minutes", help="Leave out documents modified within this many minutes (server default 10)."
70
+ ),
71
+ ] = None,
72
+ wait: Annotated[
73
+ bool,
74
+ typer.Option("--wait/--no-wait", help="Block until the run completes and print its report (default: wait)."),
75
+ ] = True,
76
+ timeout: Annotated[float, typer.Option("--timeout", help="Seconds before giving up on --wait.")] = 600.0,
77
+ poll_interval: Annotated[float, typer.Option("--poll-interval", help="Seconds between polls.")] = 3.0,
78
+ workspace: Annotated[str | None, typer.Option("--workspace", help="Workspace identifier override.")] = None,
79
+ base_url: Annotated[str | None, typer.Option("--base-url", help="API base URL override.")] = None,
80
+ output: Annotated[
81
+ str | None,
82
+ typer.Option("--output", "-o", help="Output format: text (default for humans), json (default in agent mode)."),
83
+ ] = None,
84
+ ):
85
+ """Queue a consolidation pass and, by default, wait for its report.
86
+
87
+ Report-only unless ``--apply`` is given: the pass embeds the base's
88
+ Draft and Published documents, clusters the ones above the threshold,
89
+ and lists each cluster. With ``--apply`` and a consolidator agent set
90
+ on the knowledge base, the agent decides each cluster and a merge
91
+ writes one article (union of tags, labels and source conversations)
92
+ and archives the rest. The pass runs on the worker; ``--no-wait``
93
+ prints the queued run and returns.
94
+
95
+ Examples:
96
+ fruxon knowledge-bases consolidate <kb>
97
+ fruxon knowledge-bases consolidate <kb> --apply
98
+ fruxon knowledge-bases consolidate <kb> --threshold 0.9 --no-wait -o json
99
+ """
100
+ output = resolve_output_format(output, human_default="text", agent_default="json")
101
+ if output not in {"text", "json"}:
102
+ fail(f"Unknown output format: [bold]{output}[/bold]", hint="Valid formats: text, json.", code=EXIT_VALIDATION)
103
+ if threshold is not None and not 0.5 <= threshold <= 1.0:
104
+ fail("--threshold must be between 0.5 and 1.0.", code=EXIT_VALIDATION)
105
+ if max_cluster_size is not None and max_cluster_size < 2:
106
+ fail("--max-cluster-size must be at least 2.", code=EXIT_VALIDATION)
107
+ if recent_minutes is not None and recent_minutes < 0:
108
+ fail("--recent-minutes cannot be negative.", code=EXIT_VALIDATION)
109
+ if timeout <= 0 or poll_interval <= 0:
110
+ fail("Timeout and poll interval must be positive.", code=EXIT_VALIDATION)
111
+
112
+ client = build_client(workspace, base_url)
113
+ try:
114
+ run = client.consolidate_knowledge_base(
115
+ knowledge_base,
116
+ report_only=not apply,
117
+ similarity_threshold=threshold,
118
+ max_cluster_size=max_cluster_size,
119
+ recent_write_window_minutes=recent_minutes,
120
+ )
121
+ except FruxonError as e:
122
+ fail_from_api_error(e, hint=_hint_for_error(e))
123
+
124
+ run_id = str(run.get("id", ""))
125
+ if not wait:
126
+ if output == "json":
127
+ print(json.dumps(run, indent=2))
128
+ return
129
+ say_ok(
130
+ f"Queued consolidation run [bold]{run_id}[/bold] on knowledge base [bold]{knowledge_base}[/bold].",
131
+ hint=f"Read it with: fruxon knowledge-bases consolidation {knowledge_base} {run_id}",
132
+ )
133
+ return
134
+
135
+ try:
136
+ if stderr.is_terminal and output == "text":
137
+ with stderr.status(f"[bold]Consolidating [cyan]{knowledge_base}[/cyan]…[/bold]"):
138
+ run = client.wait_for_knowledge_base_consolidation(
139
+ knowledge_base, run_id, timeout_s=timeout, poll_interval_s=poll_interval
140
+ )
141
+ else:
142
+ run = client.wait_for_knowledge_base_consolidation(
143
+ knowledge_base, run_id, timeout_s=timeout, poll_interval_s=poll_interval
144
+ )
145
+ except TimeoutError as e:
146
+ fail(str(e), hint=f"Read it later with: fruxon knowledge-bases consolidation {knowledge_base} {run_id}")
147
+ except FruxonError as e:
148
+ fail_from_api_error(e, hint=_hint_for_error(e))
149
+
150
+ _emit_run(run, output)
151
+
152
+
153
+ @knowledge_bases_app.command("consolidation")
154
+ def knowledge_bases_consolidation(
155
+ knowledge_base: Annotated[str, typer.Argument(help="Knowledge base id.")],
156
+ run: Annotated[str, typer.Argument(help="Consolidation run id, as printed by `consolidate`.")],
157
+ workspace: Annotated[str | None, typer.Option("--workspace", help="Workspace identifier override.")] = None,
158
+ base_url: Annotated[str | None, typer.Option("--base-url", help="API base URL override.")] = None,
159
+ output: Annotated[
160
+ str | None,
161
+ typer.Option("--output", "-o", help="Output format: text (default for humans), json (default in agent mode)."),
162
+ ] = None,
163
+ ):
164
+ """Read one consolidation run — its status, and the report once it completed."""
165
+ output = resolve_output_format(output, human_default="text", agent_default="json")
166
+ if output not in {"text", "json"}:
167
+ fail(f"Unknown output format: [bold]{output}[/bold]", hint="Valid formats: text, json.", code=EXIT_VALIDATION)
168
+
169
+ client = build_client(workspace, base_url)
170
+ try:
171
+ current = client.get_knowledge_base_consolidation(knowledge_base, run)
172
+ except FruxonError as e:
173
+ fail_from_api_error(e, hint=_hint_for_error(e))
174
+
175
+ _emit_run(current, output)
176
+
177
+
178
+ def _emit_run(run: dict, output: str) -> None:
179
+ if output == "json":
180
+ print(json.dumps(run, indent=2))
181
+ return
182
+
183
+ status = str(run.get("status", ""))
184
+ run_id = run.get("id", "")
185
+ if status == "FAILED":
186
+ fail(f"Consolidation run [bold]{run_id}[/bold] failed: {run.get('error') or 'no error recorded'}")
187
+ if status not in _TERMINAL:
188
+ say_info(f"Consolidation run [bold]{run_id}[/bold] is {status.lower() or 'pending'}; no report yet.")
189
+ return
190
+
191
+ report = run.get("report") or {}
192
+ _print_report(report, run)
193
+
194
+
195
+ def _print_report(report: dict, run: dict) -> None:
196
+ from rich.markup import escape
197
+ from rich.table import Table
198
+
199
+ mode = "report only" if report.get("reportOnly", True) else "applied"
200
+ agent = report.get("consolidatorAgentId")
201
+ say_ok(
202
+ f"Consolidation run [bold]{run.get('id', '')}[/bold] completed ({mode}"
203
+ + (f", agent [bold]{escape(str(agent))}[/bold]" if agent else ", no consolidator agent")
204
+ + ")."
205
+ )
206
+ stderr.print(
207
+ f" documents {report.get('documentsConsidered', 0)}"
208
+ f" · skipped as recently modified {report.get('documentsSkippedAsRecentlyModified', 0)}"
209
+ f" · embeddings computed {report.get('embeddingsComputed', 0)}"
210
+ f" / reused {report.get('embeddingsReused', 0)}"
211
+ + (f" · model {escape(str(report['embeddingModel']))}" if report.get("embeddingModel") else "")
212
+ )
213
+ stderr.print(
214
+ f" clusters {report.get('clustersFound', 0)}"
215
+ f" · merged {report.get('clustersMerged', 0)}"
216
+ f" · kept separate {report.get('clustersKeptSeparate', 0)}"
217
+ f" · failed {report.get('clustersFailed', 0)}"
218
+ f" · documents archived {report.get('documentsArchived', 0)}"
219
+ f" · threshold {report.get('similarityThreshold', '')}"
220
+ )
221
+
222
+ clusters = report.get("clusters") or []
223
+ if not clusters:
224
+ say_info("No clusters above the threshold — nothing to consolidate.")
225
+ return
226
+
227
+ for cluster in clusters:
228
+ outcome = str(cluster.get("outcome", ""))
229
+ header = (
230
+ f"[bold]#{cluster.get('index', '')}[/bold] {outcome.lower().replace('_', ' ')}"
231
+ f" · similarity {cluster.get('minSimilarity', '')}–{cluster.get('maxSimilarity', '')}"
232
+ )
233
+ if cluster.get("survivorId"):
234
+ header += f" · survivor {cluster['survivorId']}"
235
+ stderr.print(header)
236
+ table = Table(show_header=True, header_style="bold", show_edge=False, pad_edge=False, box=None, padding=(0, 1))
237
+ table.add_column("document")
238
+ table.add_column("status")
239
+ table.add_column("sources", justify="right")
240
+ table.add_column("title")
241
+ archived = set(cluster.get("archivedIds") or [])
242
+ for member in cluster.get("members") or []:
243
+ member_id = str(member.get("id", ""))
244
+ status = str(member.get("status", ""))
245
+ if member_id in archived:
246
+ status = "ARCHIVED (merged)"
247
+ table.add_row(
248
+ member_id, status, str(member.get("sourceConversationCount", 0)), escape(str(member.get("title", "")))
249
+ )
250
+ stderr.print(table)
251
+ if cluster.get("note"):
252
+ stderr.print(f" note: {escape(str(cluster['note']))}")
253
+ if report.get("clustersFailed", 0):
254
+ say_warn("Some clusters failed; their notes say why. Nothing was written for those.")
255
+
256
+
257
+ def _hint_for_error(e: FruxonError) -> str | None:
258
+ status = getattr(e, "status", None)
259
+ if status == 404:
260
+ return (
261
+ "Check the knowledge base id: fruxon assets list shows its backing asset, "
262
+ "the id here is the knowledge base's own."
263
+ )
264
+ if status == 409:
265
+ return "A pass is already queued or running on this knowledge base, or the base is archived."
266
+ return None
@@ -2437,6 +2437,76 @@ class FruxonClient:
2437
2437
  raw = self._get_json(self._assets_url(f"/{asset}/operations/{operation}"))
2438
2438
  return raw if isinstance(raw, dict) else {}
2439
2439
 
2440
+ # ── Knowledge bases ──────────────────────────────────────────────────
2441
+
2442
+ def _knowledge_bases_url(self, action: str = "") -> str:
2443
+ return f"{self._base_url}/v1/tenants/{self._workspace}/knowledgeBases{action}"
2444
+
2445
+ def consolidate_knowledge_base(
2446
+ self,
2447
+ knowledge_base: str,
2448
+ *,
2449
+ report_only: bool = True,
2450
+ similarity_threshold: float | None = None,
2451
+ max_cluster_size: int | None = None,
2452
+ recent_write_window_minutes: int | None = None,
2453
+ ) -> dict:
2454
+ """Queue a consolidation pass over a knowledge base (``POST …:consolidate``).
2455
+
2456
+ The pass runs on the maintenance worker: the reply is the queued run,
2457
+ not the report. Poll :meth:`get_knowledge_base_consolidation` or use
2458
+ :meth:`wait_for_knowledge_base_consolidation`. ``report_only`` defaults
2459
+ to True, matching the server — a pass merges nothing unless told to.
2460
+ Omitted options fall back to the server's defaults, which the run
2461
+ records.
2462
+ """
2463
+ body: dict[str, object] = {"reportOnly": report_only}
2464
+ if similarity_threshold is not None:
2465
+ body["similarityThreshold"] = similarity_threshold
2466
+ if max_cluster_size is not None:
2467
+ body["maxClusterSize"] = max_cluster_size
2468
+ if recent_write_window_minutes is not None:
2469
+ body["recentWriteWindowMinutes"] = recent_write_window_minutes
2470
+ return self._post_json(self._knowledge_bases_url(f"/{knowledge_base}:consolidate"), body)
2471
+
2472
+ def get_knowledge_base_consolidation(self, knowledge_base: str, run: str) -> dict:
2473
+ """Fetch one consolidation run; ``report`` is present once it completed."""
2474
+ raw = self._get_json(self._knowledge_bases_url(f"/{knowledge_base}/consolidations/{run}"))
2475
+ return raw if isinstance(raw, dict) else {}
2476
+
2477
+ def list_knowledge_base_consolidations(self, knowledge_base: str, *, all_pages: bool = True) -> list[dict]:
2478
+ """List consolidation runs over a knowledge base, newest first."""
2479
+ base_url = self._knowledge_bases_url(f"/{knowledge_base}/consolidations")
2480
+ items = self._fetch_all_pages(base_url, []) if all_pages else self._fetch_page(base_url, [], page_token=None)[0]
2481
+ return [item for item in items if isinstance(item, dict)]
2482
+
2483
+ def wait_for_knowledge_base_consolidation(
2484
+ self,
2485
+ knowledge_base: str,
2486
+ run: str,
2487
+ *,
2488
+ timeout_s: float = 600.0,
2489
+ poll_interval_s: float = 3.0,
2490
+ ) -> dict:
2491
+ """Poll a consolidation run until it is COMPLETED or FAILED.
2492
+
2493
+ Returns the terminal run (its ``report`` when completed, its ``error``
2494
+ when failed). Raises :class:`TimeoutError` past ``timeout_s``.
2495
+ """
2496
+ deadline = time.monotonic() + timeout_s
2497
+ last_status = None
2498
+ while True:
2499
+ current = self.get_knowledge_base_consolidation(knowledge_base, run)
2500
+ last_status = current.get("status")
2501
+ if last_status in {"COMPLETED", "FAILED"}:
2502
+ return current
2503
+ if time.monotonic() >= deadline:
2504
+ raise TimeoutError(
2505
+ f"Timed out waiting for consolidation run {run} on knowledge base {knowledge_base}."
2506
+ f" Last status: {last_status}."
2507
+ )
2508
+ time.sleep(poll_interval_s)
2509
+
2440
2510
  def list_asset_embedding_models(self) -> list[dict]:
2441
2511
  """List embedding models accepted by asset ingestion."""
2442
2512
  raw = self._get_json(self._assets_url("/embeddingModels"))