aria-code 4.1.3__py3-none-any.whl

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 (284) hide show
  1. agents/__init__.py +32 -0
  2. agents/base.py +190 -0
  3. agents/deep/__init__.py +37 -0
  4. agents/deep/calibration_loop.py +144 -0
  5. agents/deep/critic.py +125 -0
  6. agents/deep/deepen.py +193 -0
  7. agents/deep/models.py +149 -0
  8. agents/deep/pipeline.py +164 -0
  9. agents/deep/quant_fusion.py +192 -0
  10. agents/deep/themes.py +95 -0
  11. agents/deep/tiers.py +106 -0
  12. agents/financial/__init__.py +10 -0
  13. agents/financial/catalyst.py +279 -0
  14. agents/financial/debate.py +145 -0
  15. agents/financial/earnings.py +303 -0
  16. agents/financial/fundamental.py +159 -0
  17. agents/financial/macro.py +99 -0
  18. agents/financial/news.py +207 -0
  19. agents/financial/risk.py +132 -0
  20. agents/financial/sector.py +279 -0
  21. agents/financial/synthesis.py +274 -0
  22. agents/financial/technical.py +258 -0
  23. agents/portfolio_agent.py +333 -0
  24. agents/realty/__init__.py +62 -0
  25. agents/realty/asset_diagnosis.py +150 -0
  26. agents/realty/business_match.py +165 -0
  27. agents/realty/cashflow_verify.py +208 -0
  28. agents/realty/contract_rules.py +209 -0
  29. agents/realty/energy_anomaly.py +188 -0
  30. agents/realty/exit_settlement.py +207 -0
  31. agents/realty/fulfillment_risk.py +205 -0
  32. agents/realty/ops_optimize.py +159 -0
  33. agents/realty/revenue_share.py +214 -0
  34. agents/registry.py +144 -0
  35. agents/sports/__init__.py +0 -0
  36. agents/sports/football_agent.py +169 -0
  37. agents/team.py +289 -0
  38. aliyun_data_client.py +660 -0
  39. apps/README.md +12 -0
  40. apps/__init__.py +2 -0
  41. apps/channels/README.md +15 -0
  42. apps/cli/README.md +13 -0
  43. apps/cli/__init__.py +2 -0
  44. apps/cli/bootstrap.py +99 -0
  45. apps/cli/codegen_paths.py +29 -0
  46. apps/cli/commands/__init__.py +16 -0
  47. apps/cli/commands/analysis_cmds.py +288 -0
  48. apps/cli/commands/backtest_cmds.py +1887 -0
  49. apps/cli/commands/broker_cmds.py +1154 -0
  50. apps/cli/commands/business_workflow_cmds.py +289 -0
  51. apps/cli/commands/catalog.py +84 -0
  52. apps/cli/commands/data_cmds.py +405 -0
  53. apps/cli/commands/diagnostic_cmds.py +179 -0
  54. apps/cli/commands/diagnostic_ops_cmds.py +696 -0
  55. apps/cli/commands/finance_render.py +12 -0
  56. apps/cli/commands/market.py +399 -0
  57. apps/cli/commands/market_cmds.py +1276 -0
  58. apps/cli/commands/market_context.py +425 -0
  59. apps/cli/commands/market_render.py +7 -0
  60. apps/cli/commands/model_cmds.py +1579 -0
  61. apps/cli/commands/ops_cmds.py +668 -0
  62. apps/cli/commands/portfolio_cmds.py +962 -0
  63. apps/cli/commands/report.py +377 -0
  64. apps/cli/commands/scaffold_templates.py +617 -0
  65. apps/cli/commands/session_cmds.py +179 -0
  66. apps/cli/commands/session_ux_cmds.py +280 -0
  67. apps/cli/commands/team.py +588 -0
  68. apps/cli/commands/team_render.py +8 -0
  69. apps/cli/commands/ui_cmds.py +358 -0
  70. apps/cli/commands/workflow_cmds.py +279 -0
  71. apps/cli/commands/workspace_cmds.py +1414 -0
  72. apps/cli/config_paths.py +70 -0
  73. apps/cli/config_store.py +61 -0
  74. apps/cli/deterministic.py +122 -0
  75. apps/cli/direct.py +48 -0
  76. apps/cli/github_app_auth.py +135 -0
  77. apps/cli/handlers/__init__.py +11 -0
  78. apps/cli/handlers/broker_handlers.py +122 -0
  79. apps/cli/handlers/chart_handlers.py +1309 -0
  80. apps/cli/handlers/market_handlers.py +2509 -0
  81. apps/cli/handlers/realty_handlers.py +114 -0
  82. apps/cli/handlers/strategy_advice.py +82 -0
  83. apps/cli/hooks.py +180 -0
  84. apps/cli/i18n.py +284 -0
  85. apps/cli/intent.py +136 -0
  86. apps/cli/intent_router.py +217 -0
  87. apps/cli/lifecycle_hooks.py +48 -0
  88. apps/cli/main.py +29 -0
  89. apps/cli/market_metadata.py +135 -0
  90. apps/cli/market_universe.py +265 -0
  91. apps/cli/message_processing.py +257 -0
  92. apps/cli/plan_mode.py +139 -0
  93. apps/cli/plotly_html.py +15 -0
  94. apps/cli/prediction_feedback.py +202 -0
  95. apps/cli/preflight.py +497 -0
  96. apps/cli/project_aria.py +60 -0
  97. apps/cli/prompts/__init__.py +0 -0
  98. apps/cli/prompts/coding.py +658 -0
  99. apps/cli/prompts/system_prompts.py +531 -0
  100. apps/cli/prompts/ui.py +434 -0
  101. apps/cli/providers/__init__.py +1 -0
  102. apps/cli/providers/base.py +271 -0
  103. apps/cli/providers/chat_routing.py +80 -0
  104. apps/cli/providers/llm/__init__.py +1 -0
  105. apps/cli/providers/llm/ollama_stream.py +1170 -0
  106. apps/cli/providers/llm/sse_stream.py +216 -0
  107. apps/cli/providers/runtime_bridge.py +185 -0
  108. apps/cli/runtime_consumer.py +489 -0
  109. apps/cli/session_export.py +87 -0
  110. apps/cli/session_jsonl.py +207 -0
  111. apps/cli/session_store.py +112 -0
  112. apps/cli/todo_tracker.py +190 -0
  113. apps/cli/tools/__init__.py +40 -0
  114. apps/cli/tools/context.py +46 -0
  115. apps/cli/tools/file_tools.py +112 -0
  116. apps/cli/tools/market_tools.py +549 -0
  117. apps/cli/tools/notebook_tools.py +111 -0
  118. apps/cli/tools/system_tools.py +669 -0
  119. apps/cli/tools/write_tools.py +715 -0
  120. apps/cli/tradingview_bridge.py +434 -0
  121. apps/cli/update_check.py +152 -0
  122. apps/cli/utils/__init__.py +0 -0
  123. apps/cli/utils/market_detect.py +1578 -0
  124. apps/daemon/README.md +14 -0
  125. apps/vscode/README.md +115 -0
  126. apps/vscode/package.json +70 -0
  127. aria_cli.py +11636 -0
  128. aria_code-4.1.3.dist-info/METADATA +952 -0
  129. aria_code-4.1.3.dist-info/RECORD +284 -0
  130. aria_code-4.1.3.dist-info/WHEEL +5 -0
  131. aria_code-4.1.3.dist-info/entry_points.txt +2 -0
  132. aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
  133. aria_code-4.1.3.dist-info/top_level.txt +50 -0
  134. aria_daemon.py +1295 -0
  135. aria_feishu_bot.py +1359 -0
  136. aria_relay_client.py +182 -0
  137. aria_relay_server.py +405 -0
  138. aria_telegram_bot.py +202 -0
  139. ariarc.py +328 -0
  140. artifacts.py +491 -0
  141. backtest_report.py +472 -0
  142. brokers/__init__.py +72 -0
  143. brokers/base.py +207 -0
  144. brokers/capabilities.py +264 -0
  145. brokers/cn/__init__.py +10 -0
  146. brokers/cn/easytrader_broker.py +193 -0
  147. brokers/cn/futu_broker.py +194 -0
  148. brokers/cn/longbridge_broker.py +190 -0
  149. brokers/cn/tiger_broker.py +196 -0
  150. brokers/cn/xtquant_broker.py +175 -0
  151. brokers/config.py +364 -0
  152. brokers/intl/__init__.py +5 -0
  153. brokers/intl/alpaca_broker.py +183 -0
  154. brokers/intl/ibkr_broker.py +215 -0
  155. brokers/intl/webull_broker.py +156 -0
  156. brokers/paper_broker.py +259 -0
  157. brokers/planning.py +296 -0
  158. brokers/registry.py +181 -0
  159. brokers/trading.py +237 -0
  160. change_store.py +127 -0
  161. command_safety.py +19 -0
  162. computer_use_tools.py +504 -0
  163. dashboard_generator.py +578 -0
  164. data_analysis_tools.py +808 -0
  165. data_cleaner.py +483 -0
  166. data_service.py +481 -0
  167. datasources/__init__.py +23 -0
  168. datasources/base.py +166 -0
  169. datasources/router.py +221 -0
  170. datasources/sources/__init__.py +15 -0
  171. datasources/sources/akshare_source.py +269 -0
  172. datasources/sources/alpha_vantage_source.py +202 -0
  173. datasources/sources/edgar_source.py +218 -0
  174. datasources/sources/finnhub_source.py +197 -0
  175. datasources/sources/fred_source.py +219 -0
  176. datasources/sources/tushare_source.py +141 -0
  177. datasources/sources/web_scraper_source.py +278 -0
  178. datasources/sources/world_bank_source.py +205 -0
  179. datasources/sources/yfinance_source.py +152 -0
  180. demo_player.py +204 -0
  181. doctor.py +508 -0
  182. file_analysis_tools.py +734 -0
  183. finance_formulas.py +389 -0
  184. football_data_client.py +1670 -0
  185. intent_classifier.py +358 -0
  186. local_finance_tools.py +3221 -0
  187. local_llm_provider.py +552 -0
  188. macro_tools.py +368 -0
  189. market_data_client.py +1899 -0
  190. mcp_client.py +506 -0
  191. memory_manager.py +245 -0
  192. model_capability.py +416 -0
  193. notification_tools.py +248 -0
  194. packages/__init__.py +23 -0
  195. packages/aria_agents/__init__.py +5 -0
  196. packages/aria_agents/manifest.py +69 -0
  197. packages/aria_core/__init__.py +34 -0
  198. packages/aria_core/architecture.py +192 -0
  199. packages/aria_core/export.py +124 -0
  200. packages/aria_core/manifest.py +65 -0
  201. packages/aria_infra/__init__.py +15 -0
  202. packages/aria_infra/arthera.py +52 -0
  203. packages/aria_infra/doctor.py +246 -0
  204. packages/aria_infra/product.py +37 -0
  205. packages/aria_mcp/__init__.py +25 -0
  206. packages/aria_mcp/bridge.py +38 -0
  207. packages/aria_mcp/config.py +97 -0
  208. packages/aria_mcp/tools.py +61 -0
  209. packages/aria_sdk/__init__.py +19 -0
  210. packages/aria_sdk/client.py +396 -0
  211. packages/aria_sdk/providers.py +70 -0
  212. packages/aria_sdk/streaming.py +73 -0
  213. packages/aria_sdk/types.py +86 -0
  214. packages/aria_services/__init__.py +55 -0
  215. packages/aria_services/context.py +258 -0
  216. packages/aria_services/data.py +11 -0
  217. packages/aria_services/provider_health.py +189 -0
  218. packages/aria_services/registry.py +213 -0
  219. packages/aria_services/usage.py +138 -0
  220. packages/aria_skills/__init__.py +5 -0
  221. packages/aria_skills/registry.py +59 -0
  222. packages/aria_tools/__init__.py +5 -0
  223. packages/aria_tools/registry.py +128 -0
  224. packages/quant_engine/__init__.py +6 -0
  225. packages/quant_engine/sports/__init__.py +72 -0
  226. packages/quant_engine/sports/calibrator.py +353 -0
  227. packages/quant_engine/sports/dixon_coles.py +234 -0
  228. packages/quant_engine/sports/elo.py +299 -0
  229. packages/quant_engine/sports/form.py +188 -0
  230. packages/quant_engine/sports/h2h.py +195 -0
  231. packages/quant_engine/sports/ml_model.py +354 -0
  232. packages/quant_engine/sports/predictor.py +311 -0
  233. packages/quant_engine/sports/tracker.py +664 -0
  234. packages/quant_engine/stochastic/__init__.py +27 -0
  235. packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  236. packages/quant_engine/stochastic/ito_calculus.py +477 -0
  237. packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  238. packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  239. packages/quant_engine/stochastic/options_pricing.py +573 -0
  240. packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  241. plan_utils.py +194 -0
  242. plugin_loader.py +328 -0
  243. portfolio_ledger.py +262 -0
  244. privacy/__init__.py +5 -0
  245. privacy/feedback.py +123 -0
  246. project_tools.py +525 -0
  247. providers/__init__.py +30 -0
  248. providers/llm/__init__.py +19 -0
  249. providers/llm/anthropic.py +184 -0
  250. providers/llm/base.py +139 -0
  251. providers/llm/ollama.py +128 -0
  252. providers/llm/openai_compat.py +282 -0
  253. providers/llm/registry.py +358 -0
  254. realty_data_tools.py +659 -0
  255. report_generator.py +1314 -0
  256. runtime/__init__.py +103 -0
  257. runtime/agent_loop.py +1183 -0
  258. runtime/approval.py +51 -0
  259. runtime/events.py +102 -0
  260. runtime/gateway.py +128 -0
  261. runtime/lsp.py +346 -0
  262. runtime/subagent.py +258 -0
  263. runtime/tool_executor.py +104 -0
  264. runtime/tool_policy.py +106 -0
  265. safety/__init__.py +21 -0
  266. safety/permissions.py +275 -0
  267. setup_wizard.py +653 -0
  268. strategy_vault.py +420 -0
  269. ui/__init__.py +100 -0
  270. ui/banner.py +310 -0
  271. ui/completer.py +391 -0
  272. ui/console.py +271 -0
  273. ui/image_render.py +243 -0
  274. ui/input_box.py +376 -0
  275. ui/picker.py +195 -0
  276. ui/render/__init__.py +11 -0
  277. ui/render/finance.py +1480 -0
  278. ui/render/market.py +225 -0
  279. ui/render/output.py +681 -0
  280. ui/render/team.py +346 -0
  281. ui/robot.py +235 -0
  282. workspace/__init__.py +6 -0
  283. workspace/files.py +170 -0
  284. workspace/verify.py +113 -0
memory_manager.py ADDED
@@ -0,0 +1,245 @@
1
+ """
2
+ memory_manager.py — Aria Code 全局用户 memory 系统
3
+
4
+ 存储位置:~/.arthera/memory/
5
+ MEMORY.md ← 索引(每次启动加载)
6
+ user_profile.md ← 用户偏好、交易风格
7
+ project_<slug>.md ← /project load 时自动建档
8
+ research_<topic>.md ← 研究主题(用户触发时创建)
9
+
10
+ 公开 API:
11
+ MemoryManager.load_context(max_chars) → 注入 system prompt
12
+ MemoryManager.append(slug, content) → 追加一条事实
13
+ MemoryManager.upsert_project(name, facts) → 项目建档
14
+ MemoryManager.list_all() → 所有条目
15
+ MemoryManager.clear_all() → 清空全局 memory
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import re
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _MEMORY_DIR = Path.home() / ".arthera" / "memory"
30
+ _INDEX_FILE = _MEMORY_DIR / "MEMORY.md"
31
+
32
+ _PREF_PATTERNS = [
33
+ (r"(我?不喜欢|我?喜欢|prefer(?:ence)?s?|I always|I never|我总是|我通常)", "preference"),
34
+ (r"(我的风险|风险偏好|risk.*(?:低|高|中|保守|激进)|conservative|aggressive)", "risk_profile"),
35
+ (r"(?:关注|研究|在看|tracking|watching)\s*([A-Z0-9,,、和与&/\s]{2,40})", "watchlist"),
36
+ (r"(我的策略|my strategy|我用|I use)\s+(.{4,40})", "strategy"),
37
+ ]
38
+
39
+ _SENSITIVE_PATTERN = re.compile(
40
+ r"(\d+[\.,]\d{2,}(?:%|元|USD|HKD|CNY|万|亿)?|持仓|盈亏|亏损|盈利|买入|卖出|成本价)"
41
+ )
42
+
43
+
44
+ def _slugify(name: str) -> str:
45
+ name = re.sub(r"[^\w\-]", "_", name.lower())
46
+ return re.sub(r"_+", "_", name).strip("_")[:40]
47
+
48
+
49
+ class MemoryManager:
50
+ def __init__(self, root: Optional[Path] = None):
51
+ self.root = root or _MEMORY_DIR
52
+ self.root.mkdir(parents=True, exist_ok=True)
53
+ self._index = self.root / "MEMORY.md"
54
+
55
+ # ── Index management ──────────────────────────────────────────────────────
56
+
57
+ def _read_index(self) -> list[dict]:
58
+ if not self._index.exists():
59
+ return []
60
+ entries = []
61
+ for line in self._index.read_text(encoding="utf-8").splitlines():
62
+ m = re.match(r"-\s+\[(.+?)\]\((.+?)\)\s*—\s*(.*)", line)
63
+ if m:
64
+ entries.append({"title": m.group(1), "file": m.group(2), "summary": m.group(3)})
65
+ return entries
66
+
67
+ def _write_index(self, entries: list[dict]) -> None:
68
+ lines = ["# Aria Memory Index\n"]
69
+ for e in entries:
70
+ lines.append(f"- [{e['title']}]({e['file']}) — {e['summary']}")
71
+ self._index.write_text("\n".join(lines) + "\n", encoding="utf-8")
72
+
73
+ def _upsert_index(self, file: str, title: str, summary: str) -> None:
74
+ entries = self._read_index()
75
+ for e in entries:
76
+ if e["file"] == file:
77
+ e["title"] = title
78
+ e["summary"] = summary
79
+ self._write_index(entries)
80
+ return
81
+ entries.append({"title": title, "file": file, "summary": summary})
82
+ self._write_index(entries)
83
+
84
+ # ── Public API ────────────────────────────────────────────────────────────
85
+
86
+ def load_context(self, max_chars: int = 500) -> str:
87
+ """Return a compact memory block for injection into the system prompt."""
88
+ if not self._index.exists():
89
+ return ""
90
+ snippets = []
91
+ for entry in self._read_index():
92
+ fpath = self.root / entry["file"]
93
+ if not fpath.exists():
94
+ continue
95
+ text = fpath.read_text(encoding="utf-8").strip()
96
+ lines = [l for l in text.splitlines() if l.strip() and not l.startswith("#")]
97
+ snippets.extend(lines[:6])
98
+
99
+ if not snippets:
100
+ return ""
101
+
102
+ block = "\n".join(snippets)
103
+ if len(block) > max_chars:
104
+ block = block[:max_chars] + "…"
105
+ return f"## User Memory\n{block}\n"
106
+
107
+ def append(self, slug: str, content: str, title: Optional[str] = None) -> None:
108
+ """Append a fact to slug.md, creating the file if needed."""
109
+ if _SENSITIVE_PATTERN.search(content):
110
+ logger.debug("Memory: skipping sensitive content: %s…", content[:40])
111
+ return
112
+
113
+ slug = _slugify(slug)
114
+ fpath = self.root / f"{slug}.md"
115
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M")
116
+
117
+ if not fpath.exists():
118
+ _title = title or slug.replace("_", " ").title()
119
+ fpath.write_text(f"# {_title}\n\n", encoding="utf-8")
120
+ self._upsert_index(fpath.name, _title, content[:80])
121
+
122
+ with fpath.open("a", encoding="utf-8") as f:
123
+ f.write(f"- [{ts}] {content}\n")
124
+
125
+ entry = next((e for e in self._read_index() if e["file"] == fpath.name), None)
126
+ if entry:
127
+ entry["summary"] = content[:80]
128
+ self._write_index(self._read_index())
129
+
130
+ logger.debug("Memory: appended to %s: %s", fpath.name, content[:60])
131
+
132
+ def upsert_project(self, name: str, facts: dict) -> None:
133
+ """Create or refresh a project memory file."""
134
+ slug = f"project_{_slugify(name)}"
135
+ fpath = self.root / f"{slug}.md"
136
+ langs = ", ".join(facts.get("languages", [])[:4]) or "unknown"
137
+ ptype = facts.get("type", "unknown")
138
+ root = facts.get("root", "")
139
+ ts = facts.get("last_loaded", datetime.now().isoformat())[:10]
140
+ syms = ", ".join(facts.get("default_symbols", [])[:5])
141
+
142
+ lines = [
143
+ f"# Project: {name}",
144
+ f"",
145
+ f"- **type**: {ptype}",
146
+ f"- **languages**: {langs}",
147
+ f"- **root**: {root}",
148
+ f"- **last loaded**: {ts}",
149
+ ]
150
+ if syms:
151
+ lines.append(f"- **default symbols**: {syms}")
152
+
153
+ fpath.write_text("\n".join(lines) + "\n", encoding="utf-8")
154
+ summary = f"{ptype} · {langs} · last {ts}"
155
+ self._upsert_index(fpath.name, f"Project: {name}", summary)
156
+ logger.debug("Memory: upserted project %s", name)
157
+
158
+ def list_all(self) -> list[dict]:
159
+ """Return all memory entries with their file content."""
160
+ result = []
161
+ for entry in self._read_index():
162
+ fpath = self.root / entry["file"]
163
+ content = fpath.read_text(encoding="utf-8").strip() if fpath.exists() else ""
164
+ result.append({**entry, "content": content})
165
+ return result
166
+
167
+ def clear_all(self) -> int:
168
+ """Delete all memory files and reset the index. Returns count deleted."""
169
+ count = 0
170
+ for fpath in self.root.glob("*.md"):
171
+ if fpath.name != "MEMORY.md":
172
+ fpath.unlink()
173
+ count += 1
174
+ self._index.write_text("# Aria Memory Index\n", encoding="utf-8")
175
+ return count
176
+
177
+ def fact_count(self) -> int:
178
+ return len(self._read_index())
179
+
180
+
181
+ # ── Preference signal extractor (rule-based, zero LLM cost) ──────────────────
182
+
183
+ # Patterns that signal a user revealed an actionable fact mid-conversation
184
+ _MID_CONV_PATTERNS = [
185
+ # Explicit remember requests
186
+ (r"(记住|帮我记|remember that|please remember|note that)\s+(.{5,80})", "user_note"),
187
+ # Risk / style preferences revealed mid-chat
188
+ (r"(我(的)?风险|my risk|风险偏好|risk preference|risk tolerance)[^。.]{0,40}(低|高|中|保守|激进|低风险|高风险)", "risk_profile"),
189
+ (r"(我(喜欢|偏好|倾向|通常用)|I (prefer|like|usually use|always use))\s*(.{4,60})", "preference"),
190
+ # Symbols the user says they're tracking (single or multiple)
191
+ (r"(我(在看|关注|持有|跟踪)|I('m)? (watching|tracking|holding))\s*(.{2,40})", "watchlist"),
192
+ # Explicit remember / 记住 requests
193
+ (r"(记住|帮我记|please remember|note that)\s*(.{4,80})", "user_note"),
194
+ # Stop-loss / take-profit thresholds
195
+ (r"(止损|止盈|stop[- ]loss|take[- ]profit)[^\d]{0,10}(\d+\.?\d*\s*%)", "trading_rule"),
196
+ # Portfolio size hint (non-sensitive: just "大仓位" not actual amounts)
197
+ (r"(大仓|小仓|主仓|重仓|满仓|空仓|half position|full position|light position)", "position_style"),
198
+ ]
199
+
200
+ _SENSITIVE_PATTERN_STRICT = re.compile(
201
+ r"(\d[\d,,.]+(?:万|亿|元|USD|CNY|HKD|K|M)?|\b\d{5,}\b|持仓金额|账户余额|本金)"
202
+ )
203
+
204
+
205
+ def extract_preference_signal(user_msg: str, assistant_response: str) -> Optional[str]:
206
+ """Detect preference/fact signals worth persisting from a user message.
207
+
208
+ Returns a single-line fact string or None. Conservative by design —
209
+ most queries return None. Skips anything containing sensitive amounts.
210
+ """
211
+ # CJK characters are each 1 codepoint but convey more meaning per char,
212
+ # so use a shorter minimum (8 chars) to avoid filtering valid short acks.
213
+ if len(assistant_response) < 8:
214
+ return None
215
+ if _SENSITIVE_PATTERN.search(user_msg) or _SENSITIVE_PATTERN_STRICT.search(user_msg):
216
+ return None
217
+
218
+ for pattern, category in _PREF_PATTERNS + _MID_CONV_PATTERNS:
219
+ m = re.search(pattern, user_msg, re.IGNORECASE)
220
+ if m:
221
+ snippet = user_msg[:120].strip().replace("\n", " ")
222
+ return f"[{category}] {snippet}"
223
+
224
+ return None
225
+
226
+
227
+ def auto_capture_from_turn(
228
+ user_msg: str,
229
+ assistant_response: str,
230
+ memory: "MemoryManager",
231
+ ) -> Optional[str]:
232
+ """Called after each agent turn to auto-persist any detectable user preferences.
233
+
234
+ Returns the captured fact string if something was saved, else None.
235
+ This is intentionally lightweight: it runs synchronously after every turn
236
+ and must not block or throw.
237
+ """
238
+ try:
239
+ fact = extract_preference_signal(user_msg, assistant_response)
240
+ if fact:
241
+ memory.append("user_preferences", fact, title="用户偏好与设置")
242
+ return fact
243
+ except Exception:
244
+ pass
245
+ return None
model_capability.py ADDED
@@ -0,0 +1,416 @@
1
+ """
2
+ model_capability.py — Aria Code model capability registry & tool-call adapter.
3
+
4
+ Responsibilities:
5
+ - Know which local models support native tool calling vs text-based <tool_call>
6
+ - Normalise tool call output across Ollama-native / XML-tag / JSON-fenced formats
7
+ - Inject the correct tool schema format into the Ollama payload
8
+ - Detect capability dynamically when a model is not in the registry
9
+
10
+ Usage::
11
+
12
+ from model_capability import get_model_capability, build_ollama_tool_payload
13
+
14
+ caps = get_model_capability("qwen2.5-coder:7b")
15
+ # {"tool_calls": True, "format": "ollama_native", "context_window": 32768, ...}
16
+
17
+ calls = parse_tool_calls_from_response(full_text, native_tool_calls)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ from dataclasses import dataclass, field
25
+ from typing import Any, Dict, List, Optional, Tuple
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Capability catalogue
29
+ # ---------------------------------------------------------------------------
30
+
31
+ @dataclass
32
+ class ModelCapability:
33
+ # Whether the model reliably produces structured tool calls
34
+ tool_calls: bool = False
35
+ # "ollama_native" → Ollama's message.tool_calls list
36
+ # "xml_tags" → <tool_call>{"name":…,"arguments":{…}}</tool_call>
37
+ # "json_fence" → ```json\n{"tool":…,"arguments":{…}}\n```
38
+ # "text_only" → no tool calling; prompt must ask for plain-text answers
39
+ # "router_only" → model is only suitable for intent classification / routing;
40
+ # MUST NOT handle coding, analysis, or multi-step tasks
41
+ format: str = "text_only"
42
+ context_window: int = 8192
43
+ thinking: bool = False # extended-reasoning / <think> tokens
44
+ vision: bool = False # supports image / multimodal input
45
+ finance_tuned: bool = False # model has finance-domain fine-tuning
46
+ # Recommended sampling params
47
+ temperature: float = 0.3
48
+ top_p: float = 0.9
49
+ # Maximum simultaneous tool calls per round (safety limit)
50
+ max_parallel_tools: int = 1
51
+ # Minimum model size class: "nano" <1B / "small" 1-4B / "medium" 4-14B / "large" >14B
52
+ size_class: str = "medium"
53
+ # Extra notes shown in /models list
54
+ notes: str = ""
55
+
56
+
57
+ def is_router_only(cap: "ModelCapability") -> bool:
58
+ """Return True if this model must NOT handle complex tasks (coding/analysis)."""
59
+ return cap.format == "router_only"
60
+
61
+
62
+ def can_handle_coding(cap: "ModelCapability") -> bool:
63
+ """Return True if the model is large/capable enough for code generation tasks."""
64
+ return (
65
+ cap.format in ("ollama_native", "xml_tags", "anthropic_native")
66
+ and cap.context_window >= 8192
67
+ and cap.size_class not in ("nano",)
68
+ )
69
+
70
+
71
+ def can_handle_analysis(cap: "ModelCapability") -> bool:
72
+ """Return True if the model can handle multi-step financial analysis."""
73
+ return (
74
+ cap.format not in ("router_only",)
75
+ and cap.context_window >= 4096
76
+ and cap.size_class not in ("nano",)
77
+ )
78
+
79
+
80
+ # Prefix → capability mapping. Longest prefix wins.
81
+ _CAPABILITY_TABLE: Dict[str, ModelCapability] = {
82
+ # ── Qwen family ────────────────────────────────────────────────────────
83
+ "qwen2.5-coder:32b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.2, size_class="large", notes="Best local code+finance model"),
84
+ "qwen2.5-coder:14b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.2, size_class="large"),
85
+ "qwen2.5-coder:7b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.2, size_class="medium"),
86
+ "qwen2.5-coder:3b": ModelCapability(tool_calls=True, format="ollama_native", context_window=32768, temperature=0.3, size_class="small"),
87
+ # 1.5B — too small for reliable tool calls; use text_only to prevent JSON hallucination
88
+ "qwen2.5-coder:1.5b": ModelCapability(tool_calls=False, format="text_only", context_window=8192, temperature=0.4, size_class="small", notes="1.5B — no tools; simple Q&A only"),
89
+ # 0.5B — nano class; only suitable for routing/classification, never complex tasks
90
+ "qwen2.5-coder:0.5b": ModelCapability(tool_calls=False, format="router_only", context_window=4096, temperature=0.5, size_class="nano", notes="0.5B nano — routing/intent only"),
91
+ "qwen2.5-coder": ModelCapability(tool_calls=True, format="ollama_native", context_window=32768, temperature=0.2, size_class="medium"),
92
+ "qwen2.5:72b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.2, notes="Strongest Qwen general model"),
93
+ "qwen2.5:32b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.2),
94
+ "qwen2.5:14b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.2),
95
+ "qwen2.5:7b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3),
96
+ "qwen2.5": ModelCapability(tool_calls=True, format="ollama_native", context_window=32768, temperature=0.3),
97
+ "qwen3": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3, thinking=True),
98
+ "qwq": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3, thinking=True, notes="Math/reasoning focused"),
99
+ # ── DeepSeek family ────────────────────────────────────────────────────
100
+ "deepseek-r1:671b": ModelCapability(tool_calls=False, format="xml_tags", context_window=131072, temperature=0.3, thinking=True, notes="State-of-the-art reasoning"),
101
+ "deepseek-r1:70b": ModelCapability(tool_calls=False, format="xml_tags", context_window=131072, temperature=0.3, thinking=True),
102
+ "deepseek-r1:32b": ModelCapability(tool_calls=False, format="xml_tags", context_window=32768, temperature=0.3, thinking=True),
103
+ "deepseek-r1:14b": ModelCapability(tool_calls=False, format="xml_tags", context_window=32768, temperature=0.3, thinking=True),
104
+ "deepseek-r1:8b": ModelCapability(tool_calls=False, format="xml_tags", context_window=32768, temperature=0.3, thinking=True),
105
+ "deepseek-r1:7b": ModelCapability(tool_calls=False, format="xml_tags", context_window=32768, temperature=0.3, thinking=True),
106
+ "deepseek-r1": ModelCapability(tool_calls=False, format="xml_tags", context_window=32768, temperature=0.3, thinking=True),
107
+ "deepseek-v3.1": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3, notes="DeepSeek V3.1 671B"),
108
+ "deepseek-v3": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3),
109
+ "deepseek-v2.5": ModelCapability(tool_calls=True, format="ollama_native", context_window=65536, temperature=0.3),
110
+ "deepseek-coder-v2": ModelCapability(tool_calls=True, format="ollama_native", context_window=65536, temperature=0.2),
111
+ # ── LLaMA family ───────────────────────────────────────────────────────
112
+ "llama3.3:70b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3, notes="Meta flagship 2024"),
113
+ "llama3.2:90b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3, vision=True, notes="Multimodal, image+text"),
114
+ "llama3.2:11b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3, vision=True, notes="Multimodal, image+text"),
115
+ "llama3.2:3b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3),
116
+ "llama3.2": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3),
117
+ "llama3.1:405b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3),
118
+ "llama3.1:70b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3),
119
+ "llama3.1:8b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3),
120
+ "llama3.1": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3),
121
+ "llama3": ModelCapability(tool_calls=False, format="text_only", context_window=8192, temperature=0.3),
122
+ # ── Mistral family ─────────────────────────────────────────────────────
123
+ "mistral-nemo": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3),
124
+ "mistral-large": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3),
125
+ "mistral-small": ModelCapability(tool_calls=True, format="ollama_native", context_window=32768, temperature=0.3),
126
+ "mistral:7b": ModelCapability(tool_calls=True, format="ollama_native", context_window=32768, temperature=0.3),
127
+ "mistral": ModelCapability(tool_calls=True, format="ollama_native", context_window=32768, temperature=0.3),
128
+ "mixtral": ModelCapability(tool_calls=True, format="ollama_native", context_window=32768, temperature=0.3),
129
+ # ── Phi family ─────────────────────────────────────────────────────────
130
+ "phi4:14b": ModelCapability(tool_calls=True, format="ollama_native", context_window=16384, temperature=0.3, notes="Microsoft Phi4, compact+capable"),
131
+ "phi4": ModelCapability(tool_calls=True, format="ollama_native", context_window=16384, temperature=0.3),
132
+ "phi3.5": ModelCapability(tool_calls=True, format="ollama_native", context_window=16384, temperature=0.3),
133
+ "phi3": ModelCapability(tool_calls=False, format="text_only", context_window=8192, temperature=0.3),
134
+ # ── Google Gemma ───────────────────────────────────────────────────────
135
+ "gemma3:27b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3, vision=True),
136
+ "gemma3:12b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3, vision=True),
137
+ "gemma3:4b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3, vision=True),
138
+ "gemma3": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3, vision=True),
139
+ "gemma2": ModelCapability(tool_calls=False, format="text_only", context_window=8192, temperature=0.3),
140
+ # ── Vision / multimodal models ─────────────────────────────────────────────
141
+ "llava:34b": ModelCapability(tool_calls=False, format="text_only", context_window=4096, temperature=0.3, vision=True, notes="LLaVA 34B vision-language"),
142
+ "llava:13b": ModelCapability(tool_calls=False, format="text_only", context_window=4096, temperature=0.3, vision=True),
143
+ "llava:7b": ModelCapability(tool_calls=False, format="text_only", context_window=4096, temperature=0.3, vision=True),
144
+ "llava": ModelCapability(tool_calls=False, format="text_only", context_window=4096, temperature=0.3, vision=True),
145
+ "bakllava": ModelCapability(tool_calls=False, format="text_only", context_window=4096, temperature=0.3, vision=True),
146
+ "moondream": ModelCapability(tool_calls=False, format="text_only", context_window=2048, temperature=0.3, vision=True, size_class="small", notes="Tiny vision model"),
147
+ "minicpm-v": ModelCapability(tool_calls=False, format="text_only", context_window=8192, temperature=0.3, vision=True, size_class="small"),
148
+ "qwen2-vl:72b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3, vision=True, notes="Qwen2-VL 72B multimodal"),
149
+ "qwen2-vl:7b": ModelCapability(tool_calls=True, format="ollama_native", context_window=32768, temperature=0.3, vision=True),
150
+ "qwen2-vl": ModelCapability(tool_calls=True, format="ollama_native", context_window=32768, temperature=0.3, vision=True),
151
+ "qwen2.5vl:72b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3, vision=True, notes="Qwen2.5-VL 72B"),
152
+ "qwen2.5vl:7b": ModelCapability(tool_calls=True, format="ollama_native", context_window=32768, temperature=0.3, vision=True),
153
+ "qwen2.5vl": ModelCapability(tool_calls=True, format="ollama_native", context_window=32768, temperature=0.3, vision=True),
154
+ # ── Finance-specific ───────────────────────────────────────────────────
155
+ "finma": ModelCapability(tool_calls=False, format="text_only", context_window=4096, temperature=0.3, finance_tuned=True),
156
+ "fingpt": ModelCapability(tool_calls=False, format="text_only", context_window=4096, temperature=0.3, finance_tuned=True),
157
+ "bloomberggpt": ModelCapability(tool_calls=False, format="text_only", context_window=2048, temperature=0.2, finance_tuned=True),
158
+ # ── Aria own models ────────────────────────────────────────────────────
159
+ # aria-sonata-3b: primary local production model — 3B, good for finance Q&A + tool calls
160
+ "aria-sonata-3b": ModelCapability(tool_calls=False, format="xml_tags", context_window=16384, temperature=0.3, size_class="small", finance_tuned=True, notes="3B local production model"),
161
+ # aria-sonata 1.x/0.5B series — GGUF Ollama models; xml_tags for text-based tool parsing
162
+ "aria-sonata:4.5-thinking": ModelCapability(tool_calls=False, format="xml_tags", context_window=8192, temperature=0.3, size_class="small", thinking=True, finance_tuned=True),
163
+ "aria-sonata:4.5-verbose": ModelCapability(tool_calls=False, format="xml_tags", context_window=8192, temperature=0.3, size_class="small", finance_tuned=True),
164
+ "aria-sonata:4.6-thinking": ModelCapability(tool_calls=False, format="xml_tags", context_window=8192, temperature=0.3, size_class="small", thinking=True, finance_tuned=True),
165
+ "aria-sonata": ModelCapability(tool_calls=False, format="xml_tags", context_window=8192, temperature=0.3, size_class="small", finance_tuned=True),
166
+ # aria-prelude: nano router model — ONLY for intent classification and routing
167
+ "aria-prelude": ModelCapability(tool_calls=False, format="router_only", context_window=4096, temperature=0.2, size_class="nano", finance_tuned=True, notes="Nano router — intent classification only"),
168
+ # ── Anthropic Claude (cloud API via providers/llm/anthropic.py) ───────
169
+ # format="anthropic_native" → tool calling via Anthropic SDK, not Ollama
170
+ # All Claude 3+ models share 200K context and native vision support.
171
+ "claude-opus-4-8": ModelCapability(tool_calls=True, format="anthropic_native", context_window=200000, temperature=0.3, size_class="large", vision=True, thinking=True, notes="Claude Opus 4.8 — most capable"),
172
+ "claude-opus-4": ModelCapability(tool_calls=True, format="anthropic_native", context_window=200000, temperature=0.3, size_class="large", vision=True, thinking=True),
173
+ "claude-sonnet-4-6": ModelCapability(tool_calls=True, format="anthropic_native", context_window=200000, temperature=0.3, size_class="large", vision=True),
174
+ "claude-sonnet-4": ModelCapability(tool_calls=True, format="anthropic_native", context_window=200000, temperature=0.3, size_class="large", vision=True),
175
+ "claude-haiku-4-5": ModelCapability(tool_calls=True, format="anthropic_native", context_window=200000, temperature=0.3, size_class="medium", vision=True, notes="Claude Haiku 4.5 — fast/cheap"),
176
+ "claude-haiku-4": ModelCapability(tool_calls=True, format="anthropic_native", context_window=200000, temperature=0.3, size_class="medium", vision=True),
177
+ # Claude 3.x legacy — still widely used
178
+ "claude-3-7-sonnet": ModelCapability(tool_calls=True, format="anthropic_native", context_window=200000, temperature=0.3, size_class="large", vision=True, thinking=True),
179
+ "claude-3-5-sonnet": ModelCapability(tool_calls=True, format="anthropic_native", context_window=200000, temperature=0.3, size_class="large", vision=True),
180
+ "claude-3-5-haiku": ModelCapability(tool_calls=True, format="anthropic_native", context_window=200000, temperature=0.3, size_class="medium", vision=True),
181
+ "claude-3-opus": ModelCapability(tool_calls=True, format="anthropic_native", context_window=200000, temperature=0.3, size_class="large", vision=True, thinking=True),
182
+ "claude-3-sonnet": ModelCapability(tool_calls=True, format="anthropic_native", context_window=200000, temperature=0.3, size_class="large", vision=True),
183
+ "claude-3-haiku": ModelCapability(tool_calls=True, format="anthropic_native", context_window=200000, temperature=0.3, size_class="medium", vision=True),
184
+ # Generic prefix catch-all for future Claude versions (longest-prefix matching ensures
185
+ # specific entries above still win over this fallback)
186
+ "claude": ModelCapability(tool_calls=True, format="anthropic_native", context_window=200000, temperature=0.3, size_class="large", vision=True),
187
+ # ── Arthera cloud-routed models (large, run via cloud API) ────────────
188
+ "gpt-oss:120b": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3, vision=True, notes="GPT-OSS 120B cloud"),
189
+ "gpt-oss": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3, vision=True, notes="GPT-OSS cloud"),
190
+ "deepseek-v3.1:671b-cloud": ModelCapability(tool_calls=True, format="ollama_native", context_window=131072, temperature=0.3, notes="DeepSeek V3.1 671B cloud"),
191
+ }
192
+
193
+ # Default fallback when model is unknown
194
+ _DEFAULT_CAPABILITY = ModelCapability(
195
+ tool_calls=False, format="text_only", context_window=4096, temperature=0.5,
196
+ notes="Unknown model — conservative settings applied",
197
+ )
198
+
199
+
200
+ def get_model_capability(model_name: str) -> ModelCapability:
201
+ """
202
+ Return capability for *model_name* using longest-prefix matching.
203
+
204
+ Examples::
205
+
206
+ get_model_capability("qwen2.5-coder:7b-instruct-q4_K_M")
207
+ # → same as "qwen2.5-coder:7b" entry
208
+ """
209
+ name = (model_name or "").strip().lower()
210
+ # Strip GGUF quantisation suffixes like :q4_k_m, :f16, etc.
211
+ clean = re.sub(r":[qfQ][0-9].*$", "", name)
212
+ # Also strip common instruct/chat/gguf tags appended after the size
213
+ clean = re.sub(r"-(instruct|chat|gguf|base|it)$", "", clean)
214
+
215
+ best_prefix = ""
216
+ best_cap = _DEFAULT_CAPABILITY
217
+ for prefix, cap in _CAPABILITY_TABLE.items():
218
+ if clean.startswith(prefix.lower()) and len(prefix) > len(best_prefix):
219
+ best_prefix = prefix
220
+ best_cap = cap
221
+
222
+ return best_cap
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Tool schema injection helpers
227
+ # ---------------------------------------------------------------------------
228
+
229
+ def build_ollama_tool_payload(
230
+ tools_schema: List[Dict],
231
+ model_name: str,
232
+ ) -> Optional[List[Dict]]:
233
+ """
234
+ Return the `tools` field for an Ollama /api/chat request, or None when the
235
+ model does not support native tool calling.
236
+
237
+ When format == "ollama_native" the schema is passed as-is.
238
+ When format == "xml_tags" we skip the field and rely on prompt injection.
239
+ """
240
+ cap = get_model_capability(model_name)
241
+ if not cap.tool_calls or cap.format != "ollama_native":
242
+ return None
243
+ return tools_schema
244
+
245
+
246
+ def build_tool_system_prompt(
247
+ tools_schema: List[Dict],
248
+ model_name: str,
249
+ ) -> str:
250
+ """
251
+ For models that do NOT support native tool calls (xml_tags / text_only),
252
+ return a system-prompt block that instructs the model to emit
253
+ ``<tool_call>{"name":…,"arguments":{…}}</tool_call>`` tags.
254
+ """
255
+ cap = get_model_capability(model_name)
256
+ if cap.tool_calls and cap.format == "ollama_native":
257
+ return "" # handled by native API
258
+
259
+ tool_list = []
260
+ for t in tools_schema:
261
+ fn = t.get("function", t)
262
+ params = fn.get("parameters", {}).get("properties", {})
263
+ required = fn.get("parameters", {}).get("required", [])
264
+ param_str = ", ".join(
265
+ f"{k}: {v.get('type','any')}{'*' if k in required else ''}"
266
+ for k, v in params.items()
267
+ )
268
+ tool_list.append(f" - {fn['name']}({param_str}): {fn.get('description','')}")
269
+
270
+ tools_block = "\n".join(tool_list)
271
+ return (
272
+ "\n\n## Available Tools\n\n"
273
+ "When you need to call a tool, output EXACTLY this format (nothing else on the line):\n\n"
274
+ '<tool_call>{"name": "tool_name", "arguments": {"param": "value"}}</tool_call>\n\n'
275
+ "Tools available:\n"
276
+ f"{tools_block}\n\n"
277
+ "Rules:\n"
278
+ "1. Call ONE tool at a time. Wait for the result before calling the next.\n"
279
+ "2. After receiving a tool result, continue your analysis or call another tool.\n"
280
+ "3. When done with all tools, write your final answer in plain text.\n"
281
+ "4. Never make up tool results — always call the tool.\n"
282
+ )
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Tool call parsers
287
+ # ---------------------------------------------------------------------------
288
+
289
+ def parse_tool_calls_from_response(
290
+ text: str,
291
+ native_calls: Optional[List[Dict]] = None,
292
+ model_name: str = "",
293
+ ) -> List[Dict[str, Any]]:
294
+ """
295
+ Unified parser. Returns list of {"tool": str, "params": dict}.
296
+
297
+ Priority:
298
+ 1. native_calls (Ollama tool_calls list) — most reliable
299
+ 2. XML tags <tool_call>…</tool_call>
300
+ 3. JSON code fences ```json … ```
301
+ 4. Raw JSON object containing "name"/"arguments" keys
302
+ """
303
+ # 1. Native Ollama tool calls
304
+ if native_calls:
305
+ result = []
306
+ for tc in native_calls:
307
+ fn = tc.get("function", tc)
308
+ name = fn.get("name", "")
309
+ args = fn.get("arguments", {})
310
+ if isinstance(args, str):
311
+ try:
312
+ args = json.loads(args)
313
+ except json.JSONDecodeError:
314
+ args = {}
315
+ if name:
316
+ result.append({"tool": name, "params": args})
317
+ if result:
318
+ return result
319
+
320
+ if not text:
321
+ return []
322
+
323
+ results: List[Dict[str, Any]] = []
324
+
325
+ # 2. XML tag format: <tool_call>…</tool_call>
326
+ xml_pattern = re.compile(
327
+ r"<tool_call>\s*(.*?)\s*</tool_call>", re.DOTALL | re.IGNORECASE
328
+ )
329
+ for m in xml_pattern.finditer(text):
330
+ tc = _try_parse_json(m.group(1))
331
+ if tc:
332
+ results.append(_normalise_call(tc))
333
+
334
+ if results:
335
+ return results
336
+
337
+ # 3. JSON fence: ```json … ``` or ``` … ```
338
+ fence_pattern = re.compile(
339
+ r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL | re.IGNORECASE
340
+ )
341
+ for m in fence_pattern.finditer(text):
342
+ tc = _try_parse_json(m.group(1))
343
+ if tc and ("name" in tc or "tool" in tc):
344
+ results.append(_normalise_call(tc))
345
+
346
+ if results:
347
+ return results
348
+
349
+ # 4. Bare JSON object anywhere in text
350
+ json_pattern = re.compile(r"\{[^{}]*\"(?:name|tool)\"[^{}]*\}", re.DOTALL)
351
+ for m in json_pattern.finditer(text):
352
+ tc = _try_parse_json(m.group(0))
353
+ if tc and ("name" in tc or "tool" in tc):
354
+ results.append(_normalise_call(tc))
355
+
356
+ return results
357
+
358
+
359
+ def _try_parse_json(s: str) -> Optional[Dict]:
360
+ try:
361
+ return json.loads(s.strip())
362
+ except (json.JSONDecodeError, TypeError):
363
+ return None
364
+
365
+
366
+ def _normalise_call(tc: Dict) -> Dict[str, Any]:
367
+ """Normalise various key naming conventions → {"tool": …, "params": …}."""
368
+ name = tc.get("name") or tc.get("tool") or tc.get("function", {}).get("name", "")
369
+ args = (
370
+ tc.get("arguments")
371
+ or tc.get("params")
372
+ or tc.get("parameters")
373
+ or tc.get("function", {}).get("arguments", {})
374
+ or {}
375
+ )
376
+ if isinstance(args, str):
377
+ args = _try_parse_json(args) or {}
378
+ return {"tool": name, "params": args}
379
+
380
+
381
+ # ---------------------------------------------------------------------------
382
+ # Recommended local models for finance work
383
+ # ---------------------------------------------------------------------------
384
+
385
+ RECOMMENDED_FINANCE_MODELS: List[Dict[str, str]] = [
386
+ {
387
+ "model": "qwen2.5-coder:7b",
388
+ "reason": "Best balance of tool calling + code generation for finance scripts",
389
+ "install": "ollama pull qwen2.5-coder:7b",
390
+ "vram_gb": "5",
391
+ },
392
+ {
393
+ "model": "qwen2.5:14b",
394
+ "reason": "Strong quantitative reasoning, multi-turn strategy analysis",
395
+ "install": "ollama pull qwen2.5:14b",
396
+ "vram_gb": "10",
397
+ },
398
+ {
399
+ "model": "deepseek-r1:14b",
400
+ "reason": "Deep reasoning for complex factor models (slow but thorough)",
401
+ "install": "ollama pull deepseek-r1:14b",
402
+ "vram_gb": "10",
403
+ },
404
+ {
405
+ "model": "llama3.2:3b",
406
+ "reason": "Ultra-fast for quick quotes and simple questions",
407
+ "install": "ollama pull llama3.2:3b",
408
+ "vram_gb": "2",
409
+ },
410
+ {
411
+ "model": "phi4:14b",
412
+ "reason": "Math-strong, good for Greeks / derivative pricing",
413
+ "install": "ollama pull phi4",
414
+ "vram_gb": "9",
415
+ },
416
+ ]