Compare commits

...
Author SHA1 Message Date
m 342a93caa4 Add local creative fallback
CI / test (push) Successful in 1m23s
CI / test (pull_request) Successful in 1m40s
2026-08-25 11:46:03 +03:00
m 0904a4ade7 Merge pull request 'Protect deterministic creative replies' (#340) from codex/creative-chat-routing into main
CI / test (push) Successful in 1m38s
2026-08-25 08:41:03 +00:00
m bbd525eeb5 Protect creative reply from intermediate rewrites
CI / test (push) Successful in 1m33s
CI / test (pull_request) Successful in 1m24s
2026-08-25 11:37:11 +03:00
m 352703a91f Merge pull request 'Route creative chat by meaning' (#339) from codex/creative-chat-routing into main
CI / test (push) Successful in 1m20s
2026-08-25 08:32:57 +00:00
m 5a439f7ba8 Route creative chat by meaning
CI / test (push) Successful in 1m8s
CI / test (pull_request) Successful in 1m9s
2026-08-25 11:29:54 +03:00
m f7200b0704 Merge pull request 'Authorize bounded site-browser observation' (#338) from codex/site-browser-architecture into main
CI / test (push) Successful in 1m20s
2026-08-25 08:17:45 +00:00
m 14bf8261da Authorize bounded site browser observation
CI / test (push) Successful in 1m5s
CI / test (pull_request) Successful in 1m9s
2026-08-25 11:15:07 +03:00
m feb87d5c4e Merge pull request 'Improve price research queries' (#337) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m17s
2026-08-25 08:04:26 +00:00
m 24320f22a6 Improve price research queries
CI / test (push) Successful in 1m8s
CI / test (pull_request) Successful in 1m13s
2026-08-25 11:01:22 +03:00
m 98cf218cc3 Merge pull request 'Shorten research source labels' (#336) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m16s
2026-08-25 07:58:42 +00:00
m 7abe73e7bd Shorten research source labels
CI / test (push) Successful in 1m5s
CI / test (pull_request) Successful in 1m8s
2026-08-25 10:55:07 +03:00
m a05ddacff3 Merge pull request 'Clarify research results without price evidence' (#335) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m17s
2026-08-25 07:52:31 +00:00
m 20b18de60a Clarify missing price evidence
CI / test (push) Successful in 1m3s
CI / test (pull_request) Successful in 1m8s
2026-08-25 10:49:34 +03:00
m d5c98dd62f Merge pull request 'Preserve durable assistant identity and deterministic replies' (#334) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m15s
2026-08-25 07:42:01 +00:00
m 61d08fa05a Preserve durable conversation identity
CI / test (push) Successful in 1m9s
CI / test (pull_request) Successful in 1m7s
2026-08-25 10:39:05 +03:00
m 1ea2e713d8 Format interaction contract 2026-08-25 10:39:04 +03:00
m 38dce57666 Improve location time and product research 2026-08-25 10:39:04 +03:00
m 8f255b8bde Merge pull request 'Handle memory identity and travel follow-ups' (#333) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m34s
2026-08-25 07:24:45 +00:00
m 6b97d6668b Handle memory identity and travel follow-ups
CI / test (push) Successful in 1m18s
CI / test (pull_request) Successful in 1m35s
2026-08-25 10:20:54 +03:00
m 3fa1d70379 Route completed travel continuations to research 2026-08-25 10:20:53 +03:00
m 2dd05a7aa6 Merge pull request 'Continue travel tasks from departure location' (#332) from codex/chat-semantic-inventory into main
CI / test (push) Failing after 42s
2026-08-25 07:10:22 +00:00
m 02227edc23 Continue travel tasks from departure location
CI / test (push) Successful in 1m20s
CI / test (pull_request) Successful in 1m9s
2026-08-25 10:07:28 +03:00
m a2297a84ad Merge pull request 'Reject reviewed research answers without evidence' (#331) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m16s
2026-08-25 04:22:34 +00:00
m 05cbd8adc7 Reject reviewed research answers without evidence
CI / test (push) Successful in 1m5s
CI / test (pull_request) Successful in 1m12s
2026-08-25 07:19:43 +03:00
m 984731b111 Merge pull request 'Ask departure city for general travel requests' (#330) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m20s
2026-08-25 04:13:55 +00:00
m 0da03c18d1 Ask departure city for general travel requests
CI / test (push) Successful in 1m4s
CI / test (pull_request) Successful in 1m5s
2026-08-25 07:11:09 +03:00
m 302b8fc8cd Merge pull request 'Prefer current travel context in follow-up answers' (#329) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m12s
2026-08-25 04:05:00 +00:00
m 59239635cd Prefer current travel context in follow-up answers
CI / test (push) Successful in 1m9s
CI / test (pull_request) Successful in 1m2s
2026-08-25 07:02:14 +03:00
m 238105a7da Merge pull request 'Polish web research answers and travel context' (#328) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m13s
2026-08-25 03:56:31 +00:00
m d7ca5f7e68 Polish web research answers and travel context
CI / test (push) Successful in 1m11s
CI / test (pull_request) Successful in 1m5s
2026-08-25 06:53:46 +03:00
m 827135a02d Merge pull request 'Keep identity reply consistent across response fields' (#327) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m15s
2026-08-25 03:44:38 +00:00
m a3ddf53207 Keep identity reply consistent across response fields
CI / test (push) Successful in 1m7s
CI / test (pull_request) Successful in 1m8s
2026-08-25 06:41:57 +03:00
m 35ac9015ad Merge pull request 'Remove temporary continuation diagnostics' (#326) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m16s
2026-08-25 03:34:41 +00:00
m 1c196ef675 Remove continuation diagnostics
CI / test (push) Successful in 1m10s
CI / test (pull_request) Successful in 1m4s
2026-08-25 06:31:30 +03:00
m 2ce3b5a9b3 Merge pull request 'Fix semantic routing for location follow-ups' (#325) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m13s
2026-08-25 03:26:06 +00:00
m 7421fd058d Fix semantic routing for location follow-ups
CI / test (push) Successful in 1m4s
CI / test (pull_request) Successful in 1m6s
2026-08-25 06:23:38 +03:00
m 368e9df551 Merge pull request 'Trace conversational continuation selection' (#324) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m8s
2026-08-25 03:17:08 +00:00
m e9629046cb Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m3s
CI / test (pull_request) Successful in 1m6s
2026-08-25 06:15:13 +03:00
m b5ccc419f6 Trace conversational continuation selection
CI / test (push) Successful in 1m2s
CI / test (pull_request) Successful in 1m4s
2026-08-25 06:13:19 +03:00
m 615723da2a Merge pull request 'Select latest relevant local search goal' (#323) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m11s
2026-08-25 03:10:44 +00:00
m d1bcd5beff Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m4s
CI / test (pull_request) Successful in 1m15s
2026-08-25 06:09:02 +03:00
m 519dc583a0 Select latest relevant local search goal
CI / test (push) Successful in 1m4s
CI / test (pull_request) Successful in 1m6s
2026-08-25 06:07:08 +03:00
m 0bf883cf56 Merge pull request 'Continue location follow-ups from recent context' (#322) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m9s
2026-08-25 03:04:26 +00:00
m c8502c735c Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m5s
CI / test (pull_request) Successful in 1m15s
2026-08-25 06:02:39 +03:00
m 48af97fbdd Continue location follow-ups from recent context
CI / test (push) Successful in 1m16s
CI / test (pull_request) Successful in 1m6s
2026-08-25 06:00:45 +03:00
m 13bcf87ea5 Merge pull request 'Continue local search from conversation context' (#321) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m12s
2026-08-25 02:57:39 +00:00
m e271fbb1e1 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m2s
CI / test (pull_request) Successful in 1m3s
2026-08-25 05:55:23 +03:00
m 5678686c57 Continue local search from conversation context
CI / test (push) Successful in 1m8s
CI / test (pull_request) Successful in 1m7s
2026-08-25 05:53:37 +03:00
m 9fc3fd1aa5 Merge pull request 'Ask for location before proximity search' (#320) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m9s
2026-08-25 02:49:13 +00:00
m e3a9c18cb3 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m5s
CI / test (pull_request) Successful in 1m13s
2026-08-25 05:47:27 +03:00
m 9534b041db Ask for location before proximity search
CI / test (push) Successful in 1m3s
CI / test (pull_request) Successful in 1m2s
2026-08-25 05:45:40 +03:00
m 9426fa40a9 Merge pull request 'Route independent chat actions separately' (#319) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m12s
2026-08-25 02:43:11 +00:00
m 51e2b23082 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m4s
CI / test (pull_request) Successful in 1m10s
2026-08-25 05:41:29 +03:00
m aece914fb8 Route independent chat actions separately
CI / test (push) Successful in 1m6s
CI / test (pull_request) Successful in 1m1s
2026-08-25 05:39:31 +03:00
m 762e312730 Merge pull request 'Keep research replies concise' (#318) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m10s
2026-08-25 02:36:16 +00:00
m e9eb529d6e Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m6s
CI / test (pull_request) Successful in 1m10s
2026-08-25 05:34:33 +03:00
m e39a2b7dda Keep research replies concise
CI / test (push) Successful in 1m0s
CI / test (pull_request) Successful in 59s
2026-08-25 05:32:42 +03:00
m cdc6baa4c0 Merge pull request 'Align semantic reply with research result' (#317) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m10s
2026-08-25 02:29:42 +00:00
m 255ab8dbf3 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m6s
CI / test (pull_request) Successful in 1m8s
2026-08-25 05:27:59 +03:00
m a76dc42bd8 Align semantic reply with research result
CI / test (push) Successful in 1m4s
CI / test (pull_request) Successful in 1m4s
2026-08-25 05:26:06 +03:00
m f0cbd71129 Merge pull request 'Preserve travel follow-up clarification' (#316) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m12s
2026-08-25 02:23:02 +00:00
m aa10c3e093 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m1s
CI / test (pull_request) Successful in 1m13s
2026-08-25 05:21:21 +03:00
m 0d836121d0 Preserve travel follow-up clarification
CI / test (push) Successful in 1m3s
CI / test (pull_request) Successful in 1m0s
2026-08-25 05:19:27 +03:00
m 6e8ecbf5e5 Merge pull request 'Avoid inheriting stale travel goals' (#315) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m13s
2026-08-25 02:15:15 +00:00
m 8088ff197e Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m0s
CI / test (pull_request) Successful in 1m12s
2026-08-25 05:13:32 +03:00
m 55ee967f2f Avoid inheriting stale travel goals
CI / test (push) Successful in 1m4s
CI / test (pull_request) Successful in 1m1s
2026-08-25 05:11:44 +03:00
m a56bd614bd Merge pull request 'Keep semantic clarification as final reply' (#314) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m7s
2026-08-25 02:09:13 +00:00
m 992e8470b2 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m0s
CI / test (pull_request) Successful in 1m10s
2026-08-25 05:07:42 +03:00
m fe2bd8493f Keep semantic clarification as final reply
CI / test (push) Successful in 1m0s
CI / test (pull_request) Successful in 1m3s
2026-08-25 05:05:46 +03:00
m e43820c5ce Merge pull request 'Preserve travel clarification reply' (#313) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m8s
2026-08-25 02:02:50 +00:00
m 662e30cb93 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m11s
CI / test (pull_request) Successful in 1m4s
2026-08-25 05:00:46 +03:00
m a51675e58d Preserve travel clarification reply
CI / test (push) Successful in 1m1s
CI / test (pull_request) Successful in 1m0s
2026-08-25 04:58:53 +03:00
m 820b1dc643 Merge pull request 'Keep travel requests in semantic dialogue' (#312) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m9s
2026-08-25 01:55:33 +00:00
m 94f2ae81c3 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m2s
CI / test (pull_request) Successful in 1m1s
2026-08-25 04:53:28 +03:00
m 9020100313 Keep travel requests in semantic dialogue
CI / test (push) Successful in 1m7s
CI / test (pull_request) Successful in 1m1s
2026-08-25 04:51:52 +03:00
m d097940692 Merge pull request 'Stop repeat cognition for stable replies' (#311) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m9s
2026-08-25 01:44:20 +00:00
m 28dfcc8aec Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m3s
CI / test (pull_request) Successful in 1m5s
2026-08-25 04:41:44 +03:00
m 7dd0c8e7a4 Stop repeat cognition for stable replies
CI / test (push) Successful in 59s
CI / test (pull_request) Successful in 1m4s
2026-08-25 04:38:46 +03:00
m bed21ad5c0 Merge pull request 'Trace final chat response selection' (#310) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m8s
2026-08-25 01:36:18 +00:00
m 2622140529 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 58s
CI / test (pull_request) Successful in 59s
2026-08-25 04:33:36 +03:00
m 3f9dc7a270 Trace final chat response selection
CI / test (push) Successful in 59s
CI / test (pull_request) Successful in 1m1s
2026-08-25 04:30:54 +03:00
m b5633c7fab Merge pull request 'Restore stable replies after cognitive routing' (#309) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m9s
2026-08-25 01:28:07 +00:00
m 5d053fedf4 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m0s
CI / test (pull_request) Successful in 1m1s
2026-08-25 04:25:36 +03:00
m d924694602 Restore stable replies after cognitive routing
CI / test (push) Successful in 1m3s
CI / test (pull_request) Successful in 1m2s
2026-08-25 04:23:00 +03:00
m cf8a48ae0d Merge pull request 'Preserve deterministic conversational drafts' (#308) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m8s
2026-08-25 01:19:57 +00:00
m 24e865aa07 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 58s
CI / test (pull_request) Successful in 1m0s
2026-08-25 04:17:28 +03:00
m 5d0dbbbe92 Preserve deterministic conversational drafts
CI / test (push) Successful in 1m1s
CI / test (pull_request) Successful in 1m2s
2026-08-25 04:14:57 +03:00
m f2277e35b4 Merge pull request 'Keep conversational replies semantically grounded' (#307) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m9s
2026-08-25 01:11:49 +00:00
m e8a642c5e2 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m5s
CI / test (pull_request) Successful in 58s
2026-08-25 04:09:21 +03:00
m 399bdea220 Keep conversational replies semantically grounded
CI / test (push) Successful in 1m4s
CI / test (pull_request) Successful in 1m5s
2026-08-25 04:06:45 +03:00
m 9a0205758b Merge pull request 'Preserve semantic identity during reply review' (#306) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m8s
2026-08-25 01:02:14 +00:00
m 0507e1af9c Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m3s
CI / test (pull_request) Successful in 1m11s
2026-08-25 03:59:40 +03:00
m f893744ed1 Preserve semantic identity during reply review
CI / test (push) Successful in 1m0s
CI / test (pull_request) Successful in 1m4s
2026-08-25 03:57:07 +03:00
m e455e89777 Merge pull request 'Review chat replies through cognitive gateway' (#305) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m12s
2026-08-25 00:54:05 +00:00
m 9b60da96b0 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m4s
CI / test (pull_request) Successful in 1m2s
2026-08-25 03:51:39 +03:00
m 34c0e7a0f4 Review chat replies through cognitive gateway
CI / test (push) Successful in 1m5s
CI / test (pull_request) Successful in 1m0s
2026-08-25 03:49:12 +03:00
m 550ce99bcd Merge pull request 'Prioritize personalized identity reply' (#304) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m11s
2026-08-25 00:40:40 +00:00
m e81411c4a4 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 59s
CI / test (pull_request) Successful in 1m4s
2026-08-25 03:37:21 +03:00
m 688a7e49db Prioritize personalized identity reply
CI / test (push) Successful in 1m1s
CI / test (pull_request) Successful in 59s
2026-08-25 03:34:16 +03:00
m 2ce997ca73 Merge pull request 'Protect personalized identity from audit drift' (#303) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m9s
2026-08-25 00:32:03 +00:00
m 075967c395 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m2s
CI / test (pull_request) Successful in 59s
2026-08-25 03:29:23 +03:00
m 1efd8bd25d Protect personalized identity from audit drift
CI / test (push) Successful in 1m2s
CI / test (pull_request) Successful in 1m0s
2026-08-25 03:26:38 +03:00
m e2bfd83481 Merge pull request 'Trace resolved assistant identity context' (#302) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m11s
2026-08-25 00:24:11 +00:00
m 2d5f0c69d0 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m1s
CI / test (pull_request) Successful in 1m0s
2026-08-25 03:21:39 +03:00
m 339159a194 Trace resolved assistant identity context
CI / test (push) Successful in 1m0s
CI / test (pull_request) Successful in 1m0s
2026-08-25 03:18:58 +03:00
m ad905e0bc5 Merge pull request 'Trace durable interaction history volume' (#301) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m10s
2026-08-25 00:16:53 +00:00
m 721817515a Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m1s
CI / test (pull_request) Successful in 1m2s
2026-08-25 03:14:28 +03:00
m 73de357c9f Trace durable interaction history volume
CI / test (push) Successful in 1m2s
CI / test (pull_request) Successful in 1m0s
2026-08-25 03:11:51 +03:00
m b1e635d00b Merge pull request 'Log interaction memory read failures' (#300) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m8s
2026-08-25 00:09:49 +00:00
m e489b5511a Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m2s
CI / test (pull_request) Successful in 1m2s
2026-08-25 03:06:44 +03:00
m 79409fb5c8 Log interaction memory read failures
CI / test (push) Successful in 1m0s
CI / test (pull_request) Successful in 1m0s
2026-08-25 03:04:10 +03:00
m e9bfe12293 Merge pull request 'Scope durable assistant memory to principal' (#299) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m25s
2026-08-25 00:01:17 +00:00
m 0833a7a0ca Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m2s
CI / test (pull_request) Successful in 1m4s
2026-08-25 02:58:47 +03:00
m dfecab7c34 Scope durable assistant memory to principal
CI / test (push) Successful in 1m3s
CI / test (pull_request) Successful in 1m1s
2026-08-25 02:56:00 +03:00
m 2eb57675c8 Merge pull request 'Always load durable user conversation context' (#298) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m9s
2026-08-24 23:53:42 +00:00
m 98f1454b3c Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 58s
CI / test (pull_request) Successful in 57s
2026-08-25 02:51:08 +03:00
m d274768bcc Always load durable user conversation context
CI / test (push) Successful in 1m0s
CI / test (pull_request) Failing after 15s
2026-08-25 02:48:36 +03:00
m ba31326302 Merge pull request 'Preserve personalized identity replies' (#297) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m11s
2026-08-24 23:46:12 +00:00
m f4e674fa9b Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m2s
CI / test (pull_request) Successful in 58s
2026-08-25 02:43:48 +03:00
m 1c7447b7e3 Preserve personalized identity replies
CI / test (push) Successful in 1m6s
CI / test (pull_request) Successful in 56s
2026-08-25 02:41:08 +03:00
m 2d24c011aa Merge pull request 'Reuse assistant name across conversations' (#296) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m6s
2026-08-24 23:38:47 +00:00
m b9b082b802 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m5s
CI / test (pull_request) Successful in 1m4s
2026-08-25 02:35:51 +03:00
m 74dd9922a5 Reuse assistant name across conversations
CI / test (push) Successful in 1m8s
CI / test (pull_request) Successful in 1m0s
2026-08-25 02:33:15 +03:00
m 4298b63d91 Merge pull request 'Keep explicit preferences on the local route' (#295) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m14s
2026-08-24 23:30:42 +00:00
m c48fba7d8d Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m1s
CI / test (pull_request) Successful in 1m6s
2026-08-25 02:27:48 +03:00
m 7b71216a05 Keep explicit preferences on the local route
CI / test (push) Successful in 1m4s
CI / test (pull_request) Successful in 59s
2026-08-25 02:25:06 +03:00
m 35204177e9 Merge pull request 'Remember assistant name preferences in chat' (#294) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m5s
2026-08-24 23:21:17 +00:00
m fc39278c91 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m0s
CI / test (pull_request) Successful in 58s
2026-08-25 02:18:43 +03:00
m eefd54137c Remember assistant name preferences in chat
CI / test (push) Successful in 1m4s
CI / test (pull_request) Successful in 59s
2026-08-25 02:16:15 +03:00
m 880cd055dd Merge pull request 'Route travel searches to current sources' (#293) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m3s
2026-08-24 23:09:32 +00:00
m ce07163541 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m0s
CI / test (pull_request) Successful in 59s
2026-08-25 02:07:14 +03:00
m 6bd9acac6e Route travel searches to current sources
CI / test (push) Successful in 1m5s
CI / test (pull_request) Successful in 1m5s
2026-08-25 02:04:48 +03:00
m 1be14da122 Merge pull request 'Allow cold local cognition to finish' (#292) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m4s
2026-08-24 23:01:20 +00:00
m 96a61a46d0 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 59s
CI / test (pull_request) Successful in 1m0s
2026-08-25 01:58:56 +03:00
m 4f4e43c953 Allow cold local cognition to finish
CI / test (push) Successful in 1m0s
CI / test (pull_request) Successful in 57s
2026-08-25 01:56:30 +03:00
m 306a35e294 Merge pull request 'Keep ordinary why questions conversational' (#291) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m7s
2026-08-24 22:42:13 +00:00
m 53a0bdb2e3 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m3s
CI / test (pull_request) Successful in 1m0s
2026-08-25 01:39:48 +03:00
m bf73dcd1ea Keep ordinary why questions conversational
CI / test (push) Successful in 56s
CI / test (pull_request) Successful in 1m3s
2026-08-25 01:37:23 +03:00
m 6409a9d442 Merge pull request 'Recognize identity from the original question' (#290) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m7s
2026-08-24 22:33:41 +00:00
m 96f96bd130 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 55s
CI / test (pull_request) Successful in 56s
2026-08-25 01:31:25 +03:00
m a8ce97c807 Recognize identity from the original question
CI / test (push) Successful in 59s
CI / test (pull_request) Successful in 58s
2026-08-25 01:29:03 +03:00
m 0804a69b4f Merge pull request 'Keep identity replies meaningful in fallback' (#289) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m7s
2026-08-24 22:27:03 +00:00
m e816b70f46 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 59s
CI / test (pull_request) Successful in 59s
2026-08-25 01:24:35 +03:00
m 8adcd86cad Keep identity replies meaningful in fallback
CI / test (push) Successful in 58s
CI / test (pull_request) Successful in 58s
2026-08-25 01:22:05 +03:00
m 9d2db45dd5 Merge pull request 'Trim research replies to essential sources' (#288) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m1s
2026-08-24 22:15:25 +00:00
m 111276fb22 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 57s
CI / test (pull_request) Successful in 59s
2026-08-25 01:13:05 +03:00
m cad4623232 Trim research replies to essential sources
CI / test (push) Successful in 59s
CI / test (pull_request) Successful in 58s
2026-08-25 01:10:39 +03:00
m ececb58025 Merge pull request 'Preserve meaning when cognitive model is unavailable' (#287) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 59s
2026-08-24 22:02:27 +00:00
m 4a9ba3b7d4 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 55s
CI / test (pull_request) Successful in 59s
2026-08-25 01:00:14 +03:00
m b029adfb43 Keep conversational fallback on the requested meaning
CI / test (push) Successful in 1m3s
CI / test (pull_request) Successful in 1m3s
2026-08-25 00:57:48 +03:00
m d0651474ab Merge pull request 'Simplify cognitive channel status replies' (#286) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m1s
2026-08-24 21:46:35 +00:00
m ca9db836de Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m6s
CI / test (pull_request) Successful in 1m2s
2026-08-25 00:44:06 +03:00
m 44ea97b938 Simplify cognitive channel status replies
CI / test (push) Successful in 1m4s
CI / test (pull_request) Successful in 1m1s
2026-08-25 00:41:31 +03:00
m 84f5ab6c47 Merge pull request 'Use natural capability inventory wording' (#285) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m6s
2026-08-24 21:28:10 +00:00
m 153372c2de Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 56s
CI / test (pull_request) Successful in 1m4s
2026-08-25 00:25:46 +03:00
m 7384b8922b Use natural capability inventory wording
CI / test (push) Successful in 1m2s
CI / test (pull_request) Successful in 1m1s
2026-08-25 00:23:00 +03:00
m 295523b470 Merge pull request 'Simplify model inventory responses' (#284) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m0s
2026-08-24 21:07:26 +00:00
m 0991a24739 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m0s
CI / test (pull_request) Successful in 1m0s
2026-08-25 00:05:03 +03:00
m 420c52b10e Simplify model inventory responses
CI / test (push) Successful in 1m3s
CI / test (pull_request) Successful in 57s
2026-08-25 00:02:04 +03:00
m 1e3ed210d7 Merge pull request 'Remove response length meta-text from Domovoy replies' (#283) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m0s
2026-08-24 20:45:21 +00:00
m 947a75b771 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m1s
CI / test (pull_request) Successful in 1m5s
2026-08-24 23:42:02 +03:00
m c84ddad051 Remove response length meta-text from Domovoy replies
CI / test (push) Successful in 1m2s
CI / test (pull_request) Successful in 57s
2026-08-24 23:39:11 +03:00
m 71b63ac6f9 Merge pull request 'Skip retry messages when resuming chat task' (#282) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 55s
2026-08-24 20:21:01 +00:00
m 659eb4ecfc Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 57s
CI / test (pull_request) Successful in 57s
2026-08-24 23:17:53 +03:00
m 2ff2c7db09 Skip retry messages when resuming chat task
CI / test (push) Successful in 58s
CI / test (pull_request) Successful in 1m2s
2026-08-24 23:15:04 +03:00
m 475e216ee0 Merge pull request 'Carry forward previous chat tasks on retry' (#281) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 58s
2026-08-24 19:56:43 +00:00
m 226056fb46 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 56s
CI / test (pull_request) Successful in 56s
2026-08-24 22:53:56 +03:00
m 580d18a41c Carry forward previous chat tasks on retry
CI / test (push) Successful in 58s
CI / test (pull_request) Successful in 59s
2026-08-24 22:51:21 +03:00
m 0abd6c2a11 Merge pull request 'Keep chat concise and route clarification research' (#280) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m3s
2026-08-24 19:28:03 +00:00
m def43ecfc3 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 59s
CI / test (pull_request) Successful in 53s
2026-08-24 22:25:42 +03:00
m c4bfcf032a fix: keep chat concise and route clarification research
CI / test (push) Successful in 1m3s
CI / test (pull_request) Successful in 56s
2026-08-24 22:23:18 +03:00
m 4a3a7d4812 Merge pull request 'Keep Domovoy identity in builtin fallback' (#279) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m4s
2026-08-24 18:59:40 +00:00
m dbe3e023ec Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m5s
CI / test (pull_request) Successful in 56s
2026-08-24 21:57:19 +03:00
m c63110ae7d fix: keep Domovoy identity in builtin fallback
CI / test (push) Successful in 1m5s
CI / test (pull_request) Successful in 57s
2026-08-24 21:54:50 +03:00
m 101f8beadb Merge pull request 'Strengthen Domovoy identity and web retrieval' (#278) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 56s
2026-08-24 18:48:27 +00:00
m b16925b386 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 58s
CI / test (pull_request) Successful in 54s
2026-08-24 21:46:07 +03:00
m be5a146cca fix: strengthen local assistant identity and web retrieval
CI / test (push) Successful in 58s
CI / test (pull_request) Successful in 58s
2026-08-24 21:43:43 +03:00
m 094fdcc51e Merge pull request 'Complete conversational research fallback' (#277) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 54s
2026-08-24 18:36:24 +00:00
m 307ad4a587 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 54s
CI / test (pull_request) Successful in 52s
2026-08-24 21:34:13 +03:00
m e867ba3c93 fix: complete conversational research fallback
CI / test (push) Successful in 57s
CI / test (pull_request) Successful in 59s
2026-08-24 21:31:34 +03:00
m c3027c5901 Merge pull request 'Fix production creator chat timeout' (#276) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 56s
Merge PR #276: fix production creator chat timeout
2026-08-24 18:05:38 +00:00
m 30bf2d6e67 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 55s
CI / test (pull_request) Successful in 54s
2026-08-24 21:03:28 +03:00
m 7fcab3bf31 fix: allow creator chat responses to complete
CI / test (push) Successful in 59s
CI / test (pull_request) Successful in 57s
2026-08-24 21:01:05 +03:00
m eca8475e29 Merge pull request 'Semantic chat orchestration with model profiles' (#275) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 55s
Merge PR #275: semantic chat route orchestration
2026-08-24 17:28:02 +00:00
m b340d46cdd Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 54s
CI / test (pull_request) Successful in 55s
2026-08-24 20:25:49 +03:00
m ac721216ea feat: add semantic chat route orchestration
CI / test (push) Successful in 55s
CI / test (pull_request) Successful in 55s
2026-08-24 20:23:17 +03:00
m e314d02212 Merge pull request 'Route conversational questions to local cognition' (#274) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 55s
Merge PR #274: route conversational questions to local cognition
2026-08-24 17:05:59 +00:00
m 6394e45bf2 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 1m2s
CI / test (pull_request) Successful in 55s
2026-08-24 20:03:35 +03:00
m ba5cc60c15 fix: route conversational questions to local cognition
CI / test (push) Successful in 1m5s
CI / test (pull_request) Successful in 55s
2026-08-24 20:00:52 +03:00
m a0fed9956c Merge pull request 'Keep local readiness responsive when optional models stall' (#273) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m1s
Merge PR #273: keep local readiness responsive
2026-08-24 16:39:41 +00:00
m 58e9c97d76 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 55s
CI / test (pull_request) Successful in 55s
2026-08-24 19:37:18 +03:00
m ba154e4ec2 fix: keep local readiness responsive
CI / test (push) Successful in 1m0s
CI / test (pull_request) Successful in 52s
2026-08-24 19:34:35 +03:00
m c4d7795280 Merge pull request 'Keep local lifeboat context for conversational fallback' (#272) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 53s
Merge PR #272: keep local lifeboat context for conversational fallback
2026-08-24 16:27:20 +00:00
m 25323d4ac1 Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 55s
CI / test (pull_request) Successful in 53s
2026-08-24 19:24:53 +03:00
m 15133cd0fc fix: keep local chat context in lifeboat
CI / test (push) Successful in 55s
CI / test (pull_request) Successful in 50s
2026-08-24 19:22:25 +03:00
m 813554dc80 Merge pull request 'fix: keep model inventory on fast path' (#271) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 55s
Merge PR #271: keep structured model inventory on fast path
2026-08-24 16:07:52 +00:00
m bd8980bc3c Merge remote-tracking branch 'origin/main' into codex/chat-semantic-inventory
CI / test (push) Successful in 56s
CI / test (pull_request) Successful in 54s
2026-08-24 19:04:58 +03:00
m 62a3713e1e fix: keep structured model inventory on fast path
CI / test (push) Successful in 56s
CI / test (pull_request) Successful in 54s
2026-08-24 19:02:05 +03:00
m 1fb03de7be Merge pull request 'Semantic-first Domovoy chat routing and reflex drift audit' (#270) from codex/chat-semantic-inventory into main
CI / test (push) Successful in 1m19s
Merge PR #270: semantic-first Domovoy chat routing and reflex drift audit
2026-08-24 15:46:56 +00:00
m fa92a10eb7 feat: prefer semantic interpretation and audit reflexes
CI / test (push) Successful in 53s
CI / test (pull_request) Successful in 1m16s
2026-08-24 18:34:27 +03:00
m bf20f247eb fix: route model inventory requests by meaning
CI / test (push) Successful in 1m2s
2026-08-24 18:30:39 +03:00
m 560b51e2f6 Merge pull request 'DEV-000291 Record authenticated mobile acceptance' (#269) from codex/dev-000248-self-development-runtime-status into main
CI / test (push) Successful in 57s
2026-08-24 11:05:01 +00:00
37 changed files with 2274 additions and 160 deletions
+15 -2
View File
@@ -122,10 +122,10 @@ type compatibleChatResponse struct {
}
func (a *OpenAICompatibleLocalAdapter) Advise(ctx context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
if err := ValidateAdvisoryRequest(request); err != nil || request.ProviderModelID == "" || len(request.Context) != 0 {
if err := ValidateAdvisoryRequest(request); err != nil || request.ProviderModelID == "" {
return cognitive.AdvisoryResponse{}, ErrInvalidAdvisory
}
payload := compatibleChatRequest{Model: string(request.ProviderModelID), Messages: []compatibleMessage{{Role: "system", Content: "Provide non-canonical advisory text only. Never claim that an action occurred."}, {Role: "user", Content: request.Message}}, Temperature: 0, Stream: false, MaxTokens: request.MaxOutputBytes / 4, ChatTemplateKwargs: compatibleTemplateKwargs{EnableThinking: false}}
payload := compatibleChatRequest{Model: string(request.ProviderModelID), Messages: []compatibleMessage{{Role: "system", Content: "Ты отвечаешь от имени Домового — локального помощника пользователя. Не называй себя именем модели, поставщиком или внутренним runtime. Если спрашивают имя, ответь: «Меня зовут Домовой». Понимай смысл сообщения и помогай довести цель до полезного результата. По умолчанию отвечай кратко, примерно одним-тремя предложениями; подробности давай только по просьбе. Отвечай только некатегоричным текстом совета; не утверждай, что внешнее действие уже выполнено."}, {Role: "user", Content: localAdvisoryMessage(request)}}, Temperature: 0, Stream: false, MaxTokens: request.MaxOutputBytes / 4, ChatTemplateKwargs: compatibleTemplateKwargs{EnableThinking: false}}
var result compatibleChatResponse
status, err := a.requestJSON(ctx, http.MethodPost, "/chat/completions", payload, &result)
if err != nil {
@@ -151,6 +151,19 @@ func (a *OpenAICompatibleLocalAdapter) Advise(ctx context.Context, request cogni
return response, nil
}
func localAdvisoryMessage(request cognitive.AdvisoryRequest) string {
if len(request.Context) == 0 {
return request.Message
}
var builder strings.Builder
builder.WriteString(request.Message)
builder.WriteString("\n\nRelevant local context:\n")
for _, excerpt := range request.Context {
fmt.Fprintf(&builder, "- [%s/%s] %s\n", excerpt.SourceKind, excerpt.SourceID, excerpt.Text)
}
return builder.String()
}
func (a *OpenAICompatibleLocalAdapter) requestJSON(ctx context.Context, method, path string, input, output any) (int, error) {
var body io.Reader
if input != nil {
@@ -92,3 +92,32 @@ func TestOpenAICompatibleLocalAdapterDisablesTemplateThinkingForBoundedAdvisory(
t.Fatal("expected bounded advisory to disable template thinking")
}
}
func TestOpenAICompatibleLocalAdapterPassesLocalContextToAdvisory(t *testing.T) {
transport := &captureRoundTripper{}
adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", transport)
if err != nil {
t.Fatal(err)
}
_, err = adapter.Advise(context.Background(), cognitive.AdvisoryRequest{
Version: cognitive.CreatorAdvisoryV1, RequestID: "request-context", ActorID: "actor", PrincipalID: "principal", ScopeID: "scope",
Message: "Продолжи разговор", Locale: "ru-RU", MaxOutputBytes: 256, ProviderBindingID: "binding-1", ProviderBindingRevision: 1,
ProviderInstanceID: "provider-1", ModelDescriptorID: "model-1", ProviderModelID: capability.ProviderModelID("local-model"),
Context: []cognitive.ContextExcerpt{{ID: "focus", SourceKind: "situation", SourceID: "focus-1", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: "Активный проект: Домовой"}},
})
if err != nil {
t.Fatal(err)
}
var payload struct {
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
if err := json.Unmarshal(transport.body, &payload); err != nil {
t.Fatal(err)
}
if len(payload.Messages) != 2 || !strings.Contains(payload.Messages[1].Content, "Активный проект: Домовой") {
t.Fatalf("local context was not passed to advisory: %+v", payload.Messages)
}
}
@@ -3,6 +3,7 @@ package postgres
import (
"context"
"git.cin.su/m/Assistant/contracts/identity"
"git.cin.su/m/Assistant/contracts/interaction"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -98,3 +99,22 @@ func (r *InteractionRepository) Utterances(ctx context.Context, id interaction.C
}
return out, rows.Err()
}
// RecentUtterancesForPrincipal provides durable conversational context when a
// client starts a fresh conversation instead of carrying the previous ID.
func (r *InteractionRepository) RecentUtterancesForPrincipal(ctx context.Context, principalID identity.PrincipalID, limit int) ([]interaction.HumanUtterance, error) {
rows, err := r.pool.Query(ctx, `SELECT utterance_id,conversation_id,actor_id,principal_id,channel,received_at_unix_millis,correlation_id,content,content_ref,speech_act,interpretation_state,corrects_utterance_id FROM interaction.utterances WHERE principal_id=$1 ORDER BY received_at_unix_millis DESC, utterance_id DESC LIMIT $2`, principalID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []interaction.HumanUtterance
for rows.Next() {
var u interaction.HumanUtterance
if err := rows.Scan(&u.ID, &u.ConversationID, &u.ActorID, &u.PrincipalID, &u.Channel, &u.ReceivedAtUnixMillis, &u.CorrelationID, &u.Content, &u.ContentRef, &u.SpeechAct, &u.InterpretationState, &u.CorrectsUtteranceID); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
+138 -12
View File
@@ -3,6 +3,7 @@ package research
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"html"
@@ -19,9 +20,9 @@ import (
var ErrSearchRejected = errors.New("web search request rejected")
var (
searchLinkPattern = regexp.MustCompile(`(?is)<a[^>]+class=["'][^"']*result__a[^"']*["'][^>]*>(.*?)</a>`)
searchLinkPattern = regexp.MustCompile(`(?is)(<a[^>]+class=["'][^"']*result__a[^"']*["'][^>]*>)(.*?)</a>`)
searchSnippetPattern = regexp.MustCompile(`(?is)<a[^>]+class=["'][^"']*result__snippet[^"']*["'][^>]*>(.*?)</a>`)
bingLinkPattern = regexp.MustCompile(`(?is)<li[^>]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?<h2[^>]*><a[^>]*>(.*?)</a>`)
bingLinkPattern = regexp.MustCompile(`(?is)<li[^>]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?<h2[^>]*>(<a[^>]*>)(.*?)</a>`)
bingSnippetPattern = regexp.MustCompile(`(?is)<li[^>]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?<p[^>]*>(.*?)</p>`)
tagPattern = regexp.MustCompile(`(?s)<[^>]*>`)
termPattern = regexp.MustCompile(`(?i)[\p{L}\p{N}]{2,}`)
@@ -36,19 +37,53 @@ var searchStopTerms = map[string]struct{}{
// cities, weather terms, entities or model names: the complete semantic query
// comes from the interpretation layer.
type SearchConnector struct {
Client *http.Client
EndpointTemplate string
AllowedHosts map[string]struct{}
MaxBytes int64
MaxResults int
Client *http.Client
EndpointTemplate string
AllowedHosts map[string]struct{}
FallbackEndpoints []SearchEndpoint
MaxBytes int64
MaxResults int
}
// SearchEndpoint is an independently allowlisted web-search route. The
// connector tries routes in order and stops at the first relevant evidence.
// This is a source-recovery policy, not a question-type classifier.
type SearchEndpoint struct {
Template string
AllowedHosts map[string]struct{}
}
func (c SearchConnector) Collect(ctx context.Context, request epistemics.WebSearchRequest, nowUnixMillis int64) ([]epistemics.ResearchCandidate, error) {
if request.Plan.Validate() != nil || strings.TrimSpace(request.Query) == "" || nowUnixMillis < 1 || !strings.Contains(c.EndpointTemplate, "%s") {
return nil, ErrSearchRejected
}
endpoint, err := url.Parse(strings.Replace(c.EndpointTemplate, "%s", url.QueryEscape(strings.TrimSpace(request.Query)), 1))
if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" || !c.allowedHost(strings.ToLower(endpoint.Hostname())) {
endpoints := make([]SearchEndpoint, 0, 1+len(c.FallbackEndpoints))
endpoints = append(endpoints, SearchEndpoint{Template: c.EndpointTemplate, AllowedHosts: c.AllowedHosts})
endpoints = append(endpoints, c.FallbackEndpoints...)
var lastErr error
for _, candidateEndpoint := range endpoints {
results, err := c.collectEndpoint(ctx, candidateEndpoint, request, nowUnixMillis)
if err != nil {
lastErr = err
continue
}
if len(results) > 0 {
return results, nil
}
}
if lastErr != nil {
return nil, lastErr
}
return nil, nil
}
func (c SearchConnector) collectEndpoint(ctx context.Context, configured SearchEndpoint, request epistemics.WebSearchRequest, nowUnixMillis int64) ([]epistemics.ResearchCandidate, error) {
if !strings.Contains(configured.Template, "%s") {
return nil, ErrSearchRejected
}
searchQuery := enrichedSearchQuery(request.Query)
endpoint, err := url.Parse(strings.Replace(configured.Template, "%s", url.QueryEscape(searchQuery), 1))
if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" || !allowedHost(configured.AllowedHosts, strings.ToLower(endpoint.Hostname())) {
return nil, ErrSearchRejected
}
client := c.Client
@@ -63,6 +98,10 @@ func (c SearchConnector) Collect(ctx context.Context, request epistemics.WebSear
if err != nil {
return nil, ErrSearchRejected
}
// Search providers commonly return a consent/challenge shell to the
// default Go client. A neutral browser identification improves the same
// generic evidence path for every query without selecting by topic.
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; DomovoyResearch/1.0)")
response, err := client.Do(req)
if err != nil {
return nil, err
@@ -91,7 +130,8 @@ func (c SearchConnector) Collect(ctx context.Context, request epistemics.WebSear
snapshotID := epistemics.SourceSnapshotID("snapshot:search:" + base)
results := make([]epistemics.ResearchCandidate, 0, len(links))
for index, link := range links {
title := cleanHTML(string(link[1]))
linkURL := resolveSearchResultLocator(extractHref(string(link[1])), endpoint)
title := cleanHTML(string(link[2]))
snippet := ""
if index < len(snippets) {
snippet = cleanHTML(string(snippets[index][1]))
@@ -105,7 +145,7 @@ func (c SearchConnector) Collect(ctx context.Context, request epistemics.WebSear
results = append(results, epistemics.ResearchCandidate{
PropositionKey: request.PropositionKey + ":result:" + ordinal,
Statement: statement,
Source: epistemics.Source{ID: sourceID, Kind: "web-search", Locator: endpoint.String(), Title: title, Publisher: endpoint.Hostname()},
Source: epistemics.Source{ID: sourceID, Kind: "web-search", Locator: linkURL, Title: title, Publisher: sourcePublisher(linkURL, endpoint.Hostname())},
Snapshot: epistemics.SourceSnapshot{ID: snapshotID, SourceID: sourceID, ContentDigest: digestText, CapturedAtUnixMillis: nowUnixMillis, Freshness: epistemics.FreshnessCurrent},
Excerpt: epistemics.SourceExcerpt{ID: excerptID, SnapshotID: snapshotID, Text: statement, Start: 0, End: int64(len(statement))},
Evidence: epistemics.EvidenceObserved, Coverage: epistemics.CoverageComplete, Conflict: epistemics.ConflictNone,
@@ -120,6 +160,11 @@ func (c SearchConnector) Collect(ctx context.Context, request epistemics.WebSear
func searchStatementRelatesToQuery(query, statement string) bool {
terms := termPattern.FindAllString(strings.ToLower(query), -1)
content := strings.ToLower(statement)
productCue := containsSearchTerm(terms, "видеокарт", "gpu", "процессор", "телефон", "ноутбук", "монитор")
if productCue && hasNumericSearchTerm(terms) {
contentTerms := termPattern.FindAllString(content, -1)
return hasNumericSearchTerm(contentTerms) && containsSearchTerm(contentTerms, "видеокарт", "gpu", "процессор", "телефон", "ноутбук", "монитор")
}
usable := 0
for _, term := range terms {
if _, stop := searchStopTerms[term]; stop || len([]rune(term)) < 4 {
@@ -138,7 +183,38 @@ func searchStatementRelatesToQuery(query, statement string) bool {
return usable == 0
}
func (c SearchConnector) allowedHost(host string) bool { _, ok := c.AllowedHosts[host]; return ok }
func containsSearchTerm(values []string, wanted ...string) bool {
for _, value := range values {
for _, candidate := range wanted {
if strings.Contains(value, candidate) {
return true
}
}
}
return false
}
func hasNumericSearchTerm(values []string) bool {
for _, value := range values {
for _, runeValue := range value {
if runeValue >= '0' && runeValue <= '9' {
return true
}
}
}
return false
}
func enrichedSearchQuery(query string) string {
trimmed := strings.TrimSpace(query)
lower := strings.ToLower(trimmed)
if (strings.Contains(lower, "цен") || strings.Contains(lower, "стоим")) && !strings.Contains(lower, "куп") {
return trimmed + " цена купить сегодня"
}
return trimmed
}
func allowedHost(hosts map[string]struct{}, host string) bool { _, ok := hosts[host]; return ok }
func (c SearchConnector) limit() int {
if c.MaxResults <= 0 || c.MaxResults > 8 {
return 3
@@ -148,3 +224,53 @@ func (c SearchConnector) limit() int {
func cleanHTML(value string) string {
return strings.Join(strings.Fields(html.UnescapeString(tagPattern.ReplaceAllString(value, ""))), " ")
}
func extractHref(tag string) string {
match := regexp.MustCompile(`(?is)\bhref=["']([^"']+)["']`).FindStringSubmatch(tag)
if len(match) == 2 {
return match[1]
}
return ""
}
func resolveSearchResultLocator(raw string, searchEndpoint *url.URL) string {
value := strings.TrimSpace(html.UnescapeString(raw))
if value == "" {
return searchEndpoint.String()
}
parsed, err := url.Parse(value)
if err != nil {
return searchEndpoint.String()
}
if target := parsed.Query().Get("uddg"); target != "" {
value = target
parsed, err = url.Parse(target)
if err != nil {
return searchEndpoint.String()
}
}
if target := parsed.Query().Get("u"); strings.HasPrefix(target, "a1") {
if decoded, decodeErr := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(target, "a1")); decodeErr == nil {
if targetURL, parseErr := url.Parse(string(decoded)); parseErr == nil {
parsed = targetURL
}
}
}
if parsed.Scheme == "" {
parsed.Scheme = "https"
}
if parsed.Host == "" && strings.HasPrefix(value, "//") {
parsed, err = url.Parse("https:" + value)
}
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
return searchEndpoint.String()
}
return parsed.String()
}
func sourcePublisher(locator, fallback string) string {
if parsed, err := url.Parse(locator); err == nil && parsed.Hostname() != "" {
return parsed.Hostname()
}
return fallback
}
@@ -0,0 +1,57 @@
package research
import (
"context"
"crypto/tls"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.cin.su/m/Assistant/contracts/epistemics"
)
func TestSearchConnectorFallsBackToAnotherAllowedSource(t *testing.T) {
primary := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`<html><body>no usable results</body></html>`))
}))
defer primary.Close()
fallback := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`<a class="result__a" href="https://example.test">Казань: текущая температура 18 °C</a><a class="result__snippet">Наблюдается переменная облачность.</a>`))
}))
defer fallback.Close()
client := fallback.Client()
client.Transport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // test servers only
request := epistemics.WebSearchRequest{Plan: epistemics.ResearchPlan{ID: "plan:test", Question: "какая погода в Казани", SourceClasses: []string{"web-search"}, FreshnessPolicy: string(epistemics.FreshnessCurrent)}, PropositionKey: "question:test", Query: "какая погода в Казани"}
connector := SearchConnector{
Client: client,
EndpointTemplate: primary.URL + "?q=%s",
AllowedHosts: map[string]struct{}{strings.TrimPrefix(primary.URL, "https://"): {}},
FallbackEndpoints: []SearchEndpoint{{
Template: fallback.URL + "?q=%s",
AllowedHosts: map[string]struct{}{strings.TrimPrefix(fallback.URL, "https://"): {}},
}},
}
// The allowlist is hostname-only; test servers include an ephemeral port.
connector.AllowedHosts = map[string]struct{}{strings.Split(strings.TrimPrefix(primary.URL, "https://"), ":")[0]: {}}
connector.FallbackEndpoints[0].AllowedHosts = map[string]struct{}{strings.Split(strings.TrimPrefix(fallback.URL, "https://"), ":")[0]: {}}
results, err := connector.Collect(context.Background(), request, 1)
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || !strings.Contains(results[0].Statement, "18") {
t.Fatalf("fallback evidence = %#v", results)
}
if results[0].Source.Locator != "https://example.test" || results[0].Source.Publisher != "example.test" {
t.Fatalf("search result source = %#v", results[0].Source)
}
}
func TestEnrichedSearchQueryAddsPriceSignalsOnlyToPriceRequests(t *testing.T) {
if got := enrichedSearchQuery("проверь цену на видеокарту 4090"); got != "проверь цену на видеокарту 4090 цена купить сегодня" {
t.Fatalf("price query = %q", got)
}
if got := enrichedSearchQuery("какая погода в Казани"); got != "какая погода в Казани" {
t.Fatalf("non-price query = %q", got)
}
}
@@ -32,7 +32,7 @@ The system already has the correct outbound mutation foundation: Commit owns aut
## Non-goals
- No Gmail, MCP, Codex Bridge, browser automation, mobile app, voice, or self-extension loop.
- No Gmail, MCP, Codex Bridge, mobile app, voice, or self-extension loop. Browser automation is allowed only under ADR-0019 as bounded read-only observation; it is not a human-surface command or mutation channel.
- No new canonical owner.
- No generic scheduler/event-bus/autonomous-agent loop.
- No new third-party dependency.
@@ -30,7 +30,7 @@ Examples include: a reminder after notification, waiting work requiring a decisi
## Non-goals
- No Codex Bridge, Gmail, MCP, voice, mobile, browser automation, self-extension loop, or generalized orchestration.
- No Codex Bridge, Gmail, MCP, voice, mobile, self-extension loop, or generalized orchestration. Browser observation may be used only through the bounded read-only capability defined by ADR-0019 and cannot continue or mutate a response target.
- No universal object ontology.
- No reminder-specific parallel subsystem.
@@ -0,0 +1,34 @@
# ADR-0019 — Bounded Site-Browser Observation
Status: Accepted
Task: DEV-000293
## Context
Search snippets are insufficient for dynamic pages such as marketplaces: they may expose a category page while hiding the current product price behind client-side rendering. Domovoy needs a replaceable observation capability that can inspect an allowlisted rendered page without becoming an account agent, universal scraper, or external mutation path.
## Decision
1. Browser automation is allowed only as a server-owned, read-only observation capability behind the existing Capability and research adapter boundaries.
2. The browser runtime may navigate only to an explicit HTTPS host allowlist and bounded navigation policy. Browser-supplied or model-supplied authority, credentials, proxy settings, cookies, headers, download paths, or arbitrary JavaScript are rejected.
3. The capability may load public pages and wait for bounded client-side rendering. It may read visible text and explicitly declared structured-data selectors, then return an Epistemics Observation with source URL, observed time, coverage, and failure state.
4. Login, cookies, account state, CAPTCHA solving, checkout, form submission, uploads, downloads, purchases, messages, mutations, and external side effects are out of scope.
5. A blocked page, challenge, timeout, partial DOM, missing price, or conflicting value remains `UNKNOWN`, `PARTIAL`, or `NOT_OBSERVED`; it never becomes a verified fact by model interpretation alone.
6. Browser observation is optional. Search-only routing remains the fallback when the capability is unavailable, degraded, or not allowlisted for the requested host.
7. Capability owns availability, host policy, health and limits. Epistemics owns the resulting observation and provenance. No new canonical owner or database schema is introduced.
8. The first implementation may be development-only. Production activation requires independent capability health, allowlist evidence, adversarial navigation tests, and a live read-only smoke test.
## Boundaries
- Core/domain code does not import a browser or provider runtime.
- The browser adapter performs only bounded GET/navigation observation and cannot reach Commit/Executor.
- Secrets remain outside the browser runtime and its artifacts, logs, screenshots, page content and chat context.
- An observation is evidence for answer synthesis, not an instruction to mutate a site or canonical state.
## Consequences
- Dynamic public pages can provide stronger evidence than search snippets when the site permits it.
- Site-specific selectors and host policy are replaceable adapter configuration, not semantic domain logic.
- Browser unavailability is a capability gap, not a runtime-wide outage.
- The old blanket prohibition on browser automation is replaced by this narrower, testable boundary.
+43 -28
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"time"
"git.cin.su/m/Assistant/contracts/capability"
@@ -31,35 +32,16 @@ type fleetStatusResponse struct {
func fleetReadinessHandler(client *http.Client, preferred capability.FleetTier, endpoints []fleetEndpoint) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
members := make([]capability.FleetMember, 0, len(endpoints))
for _, endpoint := range endpoints {
member := capability.FleetMember{ProviderInstanceID: capability.ProviderInstanceID(endpoint.ProviderID), ModelDescriptorID: capability.ModelDescriptorID(endpoint.ModelID), Tier: endpoint.Tier, Reason: "NOT_CONFIGURED"}
if strings.TrimSpace(endpoint.URL) == "" {
members = append(members, member)
continue
}
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, endpoint.URL, nil)
if err == nil {
for name, value := range endpoint.Headers {
request.Header.Set(name, value)
}
probeClient := endpoint.Client
if probeClient == nil {
probeClient = client
}
response, requestErr := probeClient.Do(request)
if requestErr == nil {
response.Body.Close()
if response.StatusCode >= 200 && response.StatusCode < 300 {
member.Ready, member.Reason = true, "AVAILABLE"
} else {
member.Reason = fmt.Sprintf("status=%s", response.Status)
}
} else {
member.Reason = requestErr.Error()
}
}
members = append(members, member)
members = make([]capability.FleetMember, len(endpoints))
var waitGroup sync.WaitGroup
for index, endpoint := range endpoints {
waitGroup.Add(1)
go func(index int, endpoint fleetEndpoint) {
defer waitGroup.Done()
members[index] = probeFleetEndpoint(r.Context(), client, endpoint)
}(index, endpoint)
}
waitGroup.Wait()
readiness, err := fleetdomain.BuildFleetReadiness(time.Now().UnixMilli(), members)
response := fleetStatusResponse{Readiness: readiness, Preferred: preferred}
if err == nil {
@@ -76,4 +58,37 @@ func fleetReadinessHandler(client *http.Client, preferred capability.FleetTier,
})
}
func probeFleetEndpoint(parent context.Context, fallbackClient *http.Client, endpoint fleetEndpoint) capability.FleetMember {
member := capability.FleetMember{ProviderInstanceID: capability.ProviderInstanceID(endpoint.ProviderID), ModelDescriptorID: capability.ModelDescriptorID(endpoint.ModelID), Tier: endpoint.Tier, Reason: "NOT_CONFIGURED"}
if strings.TrimSpace(endpoint.URL) == "" {
return member
}
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.URL, nil)
if err != nil {
member.Reason = err.Error()
return member
}
for name, value := range endpoint.Headers {
request.Header.Set(name, value)
}
probeClient := endpoint.Client
if probeClient == nil {
probeClient = fallbackClient
}
response, requestErr := probeClient.Do(request)
if requestErr != nil {
member.Reason = requestErr.Error()
return member
}
defer response.Body.Close()
if response.StatusCode >= 200 && response.StatusCode < 300 {
member.Ready, member.Reason = true, "AVAILABLE"
} else {
member.Reason = fmt.Sprintf("status=%s", response.Status)
}
return member
}
func fleetContext(ctx context.Context) context.Context { return ctx }
+24
View File
@@ -5,6 +5,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"git.cin.su/m/Assistant/contracts/capability"
)
@@ -26,6 +27,29 @@ func TestFleetReadinessSelectsBestAvailablePrimaryTierBeforeLifeboat(t *testing.
}
}
func TestFleetReadinessDoesNotWaitForSlowMembersSerially(t *testing.T) {
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(150 * time.Millisecond)
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer slow.Close()
fast := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
defer fast.Close()
handler := fleetReadinessHandler(fast.Client(), capability.FleetTierFast, []fleetEndpoint{
{ProviderID: "slow", ModelID: "slow-model", Tier: capability.FleetTierMedium, URL: slow.URL},
{ProviderID: "lifeboat", ModelID: "lifeboat-model", Tier: capability.FleetTierLifeboat, URL: fast.URL},
})
started := time.Now()
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/fleet/readiness", nil))
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"selected_tier":"LIFEBOAT"`) {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if elapsed := time.Since(started); elapsed >= 300*time.Millisecond {
t.Fatalf("fleet readiness was serialized: elapsed=%s", elapsed)
}
}
func TestFleetReadinessSendsConfiguredProviderHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer fleet-key" {
+41 -7
View File
@@ -186,27 +186,61 @@ func selectionObjectiveForMeaning(meaning cognitive.TaskMeaning, message string)
}
func builtinAdvisory(request cognitive.GatewayAdvisoryRequest) cognitive.GatewayAdvisoryResponse {
text := "Я рядом и продолжаю работу во встроенном режиме Домового. Внешний когнитивный маршрут сейчас недоступен, поэтому опираюсь на уже установленный смысл сообщения и локальные возможности."
if request.Meaning.SpeechAct == "RESPONSE_REVIEW" {
const marker = "Черновик ответа:"
if index := strings.LastIndex(request.Message, marker); index >= 0 {
if draft := strings.TrimSpace(request.Message[index+len(marker):]); draft != "" {
return cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: draft, FinishReason: cognitive.FinishCompleted}
}
}
}
text := "Понял."
identityText := request.Meaning.RequestedOutcome
if identityText == "" {
identityText = request.Message
}
if request.Meaning.SpeechAct == "QUESTION" && isIdentityInquiry(identityText) {
text = "Я — Домовой, ваш помощник."
}
switch request.Meaning.InformationRequestKind {
case interaction.InformationRequestCognitiveModelInventory:
text = "Запрос понят как проверка моделей. Встроенный режим Домового доступен; я могу показать подключённые модели по назначению, качеству, скорости и стоимости. Ключи и пароли в разговор не нужны."
text = "Сейчас не смог проверить подключённые модели."
case interaction.InformationRequestCapabilityInventory:
text = "Запрос понят как проверка возможностей. Я могу объяснить доступные capabilities и отличить то, что уже подключено, от того, что пока является только планом или capability gap."
text = "Сейчас не смог проверить доступные возможности."
case interaction.InformationRequestCognitiveChannelStatus:
text = "Запрос понят как проверка когнитивного канала. Локальный режим остаётся доступен; я объясню наблюдаемое состояние и что можно сделать без ожидания внешней модели."
text = "Локальный канал Домового доступен; подробности состояния дам по запросу."
case interaction.InformationRequestCognitiveRecovery:
text = "Запрос понят как восстановление ИИ. Начинаю с локального маршрута: фиксирую, что недоступно, проверяю безопасные варианты восстановления и сформулирую следующий обратимый шаг."
text = "Встроенный канал сейчас недоступен."
default:
switch request.Meaning.SpeechAct {
case "REQUEST_TO_INVESTIGATE":
text = "Я принял это как исследование. Даже без внешней модели могу сформулировать гипотезы, отделить известное от неизвестного и предложить следующий проверяемый шаг."
text = "Понял задачу, но пока не смог получить проверяемый результат."
case "QUESTION":
text = "Я отвечаю во встроенном режиме Домового. Если для полного ответа не хватает внешней модели, объясню это прямо и продолжу с тем, что можно проверить локально."
if !isIdentityInquiry(identityText) {
text = "Пока не смог получить проверяемый ответ."
}
case "CONVERSATIONAL":
text = "Я — Домовой, ваш помощник."
}
}
return cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: text, FinishReason: cognitive.FinishCompleted}
}
func isIdentityInquiry(message string) bool {
tokens := strings.Fields(strings.ToLower(strings.NewReplacer("?", " ", "!", " ", ",", " ").Replace(message)))
hasIdentitySubject := false
hasAssistantReference := false
for _, token := range tokens {
switch token {
case "кто", "зовут", "имя":
hasIdentitySubject = true
case "ты", "тебя", "тебе", "вы", "вас":
hasAssistantReference = true
}
}
return hasIdentitySubject && hasAssistantReference
}
func (g cognitiveGateway) invokeBuiltinAdvisory(ctx context.Context, request cognitive.GatewayAdvisoryRequest, maxOutputBytes int) cognitive.GatewayAdvisoryResponse {
if g.builtinLocal != nil && g.builtinLocal.Provider != nil {
invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: request.Message, Context: request.Context, Locale: request.Locale, MaxOutputBytes: maxOutputBytes, ProviderBindingID: "builtin-local-binding-v1", ProviderBindingRevision: 1, ProviderInstanceID: "builtin-local-lifeboat-v1", ModelDescriptorID: "builtin-local-model-v1", ProviderModelID: "domovoy-lifeboat"}
+24 -1
View File
@@ -185,11 +185,34 @@ func TestNoActiveBindingUsesBuiltInProviderWithoutInvocation(t *testing.T) {
func TestBuiltInAdvisoryUsesTrustedInformationIntentInsteadOfMessageKeywords(t *testing.T) {
request := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", Message: "arbitrary text", Meaning: cognitive.TaskMeaning{InformationRequestKind: interaction.InformationRequestCognitiveRecovery}}
response := builtinAdvisory(request)
if !strings.Contains(response.AdvisoryText, "восстановление ИИ") {
if response.AdvisoryText != "Встроенный канал сейчас недоступен." {
t.Fatalf("unexpected recovery response: %q", response.AdvisoryText)
}
}
func TestBuiltInAdvisoryIdentityIsNaturalAndDoesNotMentionResponseLength(t *testing.T) {
response := builtinAdvisory(cognitive.GatewayAdvisoryRequest{
Version: cognitive.CreatorAdvisoryV1,
RequestID: "request-identity",
Meaning: cognitive.TaskMeaning{SpeechAct: "QUESTION", RequestedOutcome: "ты кто?"},
})
if response.AdvisoryText != "Я — Домовой, ваш помощник." {
t.Fatalf("unexpected identity response: %q", response.AdvisoryText)
}
if strings.Contains(response.AdvisoryText, "коротко") || strings.Contains(response.AdvisoryText, "подробност") {
t.Fatalf("identity response contains style meta-text: %q", response.AdvisoryText)
}
messageResponse := builtinAdvisory(cognitive.GatewayAdvisoryRequest{
Version: cognitive.CreatorAdvisoryV1,
RequestID: "request-identity-message",
Message: "как тебя зовут?",
Meaning: cognitive.TaskMeaning{SpeechAct: "QUESTION"},
})
if messageResponse.AdvisoryText != "Я — Домовой, ваш помощник." {
t.Fatalf("unexpected message identity response: %q", messageResponse.AdvisoryText)
}
}
func TestNoActiveBindingInvokesBuiltInLocalProvider(t *testing.T) {
registry := &registryStub{err: errors.New("no active binding")}
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", AdvisoryText: "локальный ответ", FinishReason: cognitive.FinishCompleted}}
+5 -3
View File
@@ -126,7 +126,9 @@ func main() {
}
cpuEndpoint := strings.TrimRight(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), "/") + "/models"
cpuHealthProbe := health.OptionalHTTPProbeWithHeaders(cpuReadinessClient, cpuEndpoint, cpuFleetHeaders)
cpuHealthProbe.Timeout = 10 * time.Second
// Opportunistic providers must not hide a ready local lifeboat. Keep their
// readiness budget short; their detailed fleet state is reported separately.
cpuHealthProbe.Timeout = 2 * time.Second
mediumEndpoint := ""
mediumHeaders := map[string]string{}
mediumHealthProbe := health.Probe{}
@@ -136,7 +138,7 @@ func main() {
mediumHeaders["Authorization"] = "Bearer " + mediumAPIKey
}
mediumHealthProbe = health.OptionalHTTPProbeWithHeaders(mediumReadinessClient, mediumEndpoint, mediumHeaders)
mediumHealthProbe.Timeout = 10 * time.Second
mediumHealthProbe.Timeout = 2 * time.Second
}
readinessProbes := []health.Probe{
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), "/")+"/healthz"),
@@ -149,7 +151,7 @@ func main() {
if gpuConfigured {
gpuHeaders := map[string]string{"Authorization": "Bearer " + gpuKey}
gpuProbe := health.OptionalHTTPProbeWithHeaders(readinessClient, strings.TrimRight(gpuURL, "/")+"/models", gpuHeaders)
gpuProbe.Timeout = 10 * time.Second
gpuProbe.Timeout = 2 * time.Second
readinessProbes = append(readinessProbes, gpuProbe)
}
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"sort"
"git.cin.su/m/Assistant/contracts/capability"
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
)
type chatRouteKind string
const (
chatRouteResearch chatRouteKind = "RESEARCH"
chatRouteConversation chatRouteKind = "CONVERSATION"
chatRouteAction chatRouteKind = "ACTION"
chatRouteAgent chatRouteKind = "AGENT"
)
type chatRoute struct {
Kind chatRouteKind
Capability capability.CapabilityKind
Profile string
Order int
}
// buildChatRoutePlan creates one bounded route per meaning-bearing capability.
// A failed research route must not suppress the local conversational route or
// an unrelated action route.
func buildChatRoutePlan(result interactiondomain.SemanticResult) []chatRoute {
seen := make(map[capability.CapabilityKind]struct{})
routes := make([]chatRoute, 0, len(result.CapabilityHints)+1)
add := func(kind chatRouteKind, capabilityKind capability.CapabilityKind, profile string, order int) {
if capabilityKind != "" {
if _, exists := seen[capabilityKind]; exists {
return
}
seen[capabilityKind] = struct{}{}
}
routes = append(routes, chatRoute{Kind: kind, Capability: capabilityKind, Profile: profile, Order: order})
}
for _, hint := range result.CapabilityHints {
switch capability.CapabilityKind(hint) {
case capability.CapabilityKindWebResearch:
add(chatRouteResearch, capability.CapabilityKindWebResearch, "truth-research-v1", 10)
case capability.CapabilityKindCognitiveReasoning, capability.CapabilityKindVerifyFact:
add(chatRouteConversation, capability.CapabilityKind(hint), "local-reasoning-v1", 20)
case capability.CapabilityKindScheduleReminder:
add(chatRouteAction, capability.CapabilityKindScheduleReminder, "continuity-reminder-v1", 30)
case capability.CapabilityKindModifyCode, capability.CapabilityKindRunTests, capability.CapabilityKindInspectProject:
add(chatRouteAgent, capability.CapabilityKind(hint), "software-agent-v1", 40)
case capability.CapabilityKindSendEmail, capability.CapabilityKindReadEmail:
add(chatRouteAgent, capability.CapabilityKind(hint), "communication-agent-v1", 50)
case capability.CapabilityKindReadDatabase, capability.CapabilityKindQueryLogs:
add(chatRouteAgent, capability.CapabilityKind(hint), "diagnostics-agent-v1", 60)
}
}
if result.NeedsCognition || len(routes) == 0 {
add(chatRouteConversation, capability.CapabilityKindCognitiveReasoning, "local-reasoning-v1", 20)
}
sort.SliceStable(routes, func(i, j int) bool { return routes[i].Order < routes[j].Order })
return routes
}
func hasChatRoute(routes []chatRoute, kind chatRouteKind) bool {
for _, route := range routes {
if route.Kind == kind {
return true
}
}
return false
}
@@ -0,0 +1,41 @@
package main
import (
"testing"
"git.cin.su/m/Assistant/contracts/capability"
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
)
func TestBuildChatRoutePlanPreservesMultipleMeaningfulRoutes(t *testing.T) {
planned := buildChatRoutePlan(interactiondomain.SemanticResult{
NeedsCognition: true,
CapabilityHints: []string{"WEB_RESEARCH", "MODIFY_CODE", "RUN_TESTS", "SCHEDULE_REMINDER"},
})
if len(planned) != 5 {
t.Fatalf("expected five deduplicated routes, got %+v", planned)
}
want := []struct {
kind chatRouteKind
profile string
cap capability.CapabilityKind
}{
{chatRouteResearch, "truth-research-v1", capability.CapabilityKindWebResearch},
{chatRouteConversation, "local-reasoning-v1", capability.CapabilityKindCognitiveReasoning},
{chatRouteAction, "continuity-reminder-v1", capability.CapabilityKindScheduleReminder},
{chatRouteAgent, "software-agent-v1", capability.CapabilityKindModifyCode},
{chatRouteAgent, "software-agent-v1", capability.CapabilityKindRunTests},
}
for index, expected := range want {
if planned[index].Kind != expected.kind || planned[index].Profile != expected.profile || planned[index].Capability != expected.cap {
t.Fatalf("route %d = %+v, want %+v", index, planned[index], expected)
}
}
}
func TestBuildChatRoutePlanAlwaysKeepsLocalConversationRoute(t *testing.T) {
planned := buildChatRoutePlan(interactiondomain.SemanticResult{NeedsCognition: true})
if len(planned) != 1 || planned[0].Kind != chatRouteConversation || planned[0].Profile != "local-reasoning-v1" {
t.Fatalf("unexpected local route: %+v", planned)
}
}
+108 -4
View File
@@ -5,8 +5,11 @@ import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"net/http"
"net/url"
"strings"
"time"
"git.cin.su/m/Assistant/contracts/authority"
"git.cin.su/m/Assistant/contracts/capability"
@@ -22,6 +25,56 @@ type cognitiveInterpretationClient struct {
client *http.Client
}
// ReviewResponse asks the cognitive gateway to turn a locally assembled draft
// into a natural answer. The draft remains the safe fallback when cognition is
// unavailable; the model does not mutate canonical state or execute actions.
func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, session identity.CreatorSession, userMessage, draft string, meaning cognitive.TaskMeaning) (string, error) {
requestID, err := ids.New()
if err != nil {
return "", err
}
prompt := "Ты редактор ответа Домового. Проверь черновик по смыслу и подготовь естественный ответ человеку. Учитывай весь запрос, включая недостающие параметры для конкретного результата. Если данных не хватает, задай один короткий полезный уточняющий вопрос, например откуда и куда ехать. Не выдавай поисковые сниппеты как выполненное действие и не показывай служебные URL поисковой выдачи. Сохрани проверяемые факты, названия источников, прямые ссылки и ограничения. Не выдумывай факты и не утверждай невыполненные действия. Верни только готовый ответ без разбора проверки, служебных слов и упоминания моделей. Если черновик уже адекватен, верни его без лишних добавлений.\n\nСообщение пользователя:\n" + userMessage + "\n\nЧерновик ответа:\n" + draft
target := *c.baseURL
target.Path = "/v1/advisories"
payload, err := json.Marshal(cognitive.GatewayAdvisoryRequest{
Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID),
ActorID: session.ActorID, PrincipalID: session.PrincipalID,
ScopeID: authority.ScopeID("domovoy-global-scope"), Message: prompt,
Locale: "ru", MaxOutputBytes: 2048, DataPolicy: capability.DataPolicyLocalOnly,
Meaning: cognitive.TaskMeaning{
SpeechAct: "RESPONSE_REVIEW",
CapabilityHints: meaning.CapabilityHints,
RequestedOutcome: meaning.RequestedOutcome,
InformationRequestKind: meaning.InformationRequestKind,
ConfidenceMillis: meaning.ConfidenceMillis,
},
})
if err != nil {
return "", err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
if err != nil {
return "", err
}
request.Header.Set("Content-Type", "application/json")
response, err := c.client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("cognitive response review status %d", response.StatusCode)
}
var result cognitive.GatewayAdvisoryResponse
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return "", err
}
if result.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(result.AdvisoryText) == "" {
return "", fmt.Errorf("cognitive response review incomplete: %s", result.FinishReason)
}
return strings.TrimSpace(result.AdvisoryText), nil
}
func newCognitiveInterpretationClient(rawURL string, client *http.Client) (cognitiveInterpretationClient, error) {
base, err := url.Parse(rawURL)
if err != nil || base.Scheme == "" || base.Host == "" {
@@ -61,12 +114,63 @@ func (c cognitiveInterpretationClient) Interpret(ctx context.Context, request co
}
func shouldRequestBroaderInterpretation(result interactiondomain.SemanticResult) bool {
switch result.Interpretation.Applicability {
case interaction.SemanticNeedsBroaderInterpret, interaction.SemanticLowConfidence, interaction.SemanticMixedIntent:
return true
default:
return shouldRequestSemanticInterpretation(result)
}
// shouldRequestSemanticInterpretation keeps the deterministic layer as a
// fast reflex, not as the meaning authority. Questions, requests, directives,
// mixed signals and capability-bearing messages go through cognitive
// interpretation; only stable low-risk conversational/assertive reflexes may
// answer without it.
func shouldRequestSemanticInterpretation(result interactiondomain.SemanticResult) bool {
if result.Interpretation.Clarification == interaction.ClarificationRequired {
return false
}
// Structured, read-only information intents already have a semantic owner
// in core-runtime. Sending them through the slower advisory interpretation
// path only adds latency and can make a healthy runtime look unavailable.
if result.InformationRequestKind != "" {
return false
}
if result.NeedsCognition || len(result.CapabilityHints) > 0 {
return true
}
if result.Interpretation.Applicability != interaction.SemanticApplicable {
return true
}
switch result.Interpretation.SpeechAct {
case interaction.SpeechActConversational, interaction.SpeechActConfirmation, interaction.SpeechActRejection:
return false
case interaction.SpeechActPreference:
// Explicit user preferences are durable interaction context. A sampled
// advisory audit must not reinterpret or erase them.
return false
case interaction.SpeechActAssertion:
return result.Interpretation.ConfidenceMillis < 850
default:
return true
}
}
const (
deterministicReflexAuditInterval = 10 * time.Minute
deterministicReflexAuditSample = uint32(16)
)
// shouldAuditDeterministicReflex samples stable reflexes in a time bucket.
// This is observability, not a correctness gate: a failed audit leaves the
// reflex usable, while a successful disagreement can replace it with the
// cognitive candidate and becomes durable evidence through the normal
// candidate store.
func shouldAuditDeterministicReflex(result interactiondomain.SemanticResult, utteranceID string, now time.Time) bool {
if shouldRequestSemanticInterpretation(result) || utteranceID == "" {
return false
}
bucket := now.Unix() / int64(deterministicReflexAuditInterval/time.Second)
hash := fnv.New32a()
_, _ = hash.Write([]byte(utteranceID))
_, _ = hash.Write([]byte(fmt.Sprintf(":%d", bucket)))
return hash.Sum32()%deterministicReflexAuditSample == 0
}
func isSimpleLocalQuestion(result interactiondomain.SemanticResult) bool {
@@ -1,12 +1,55 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.cin.su/m/Assistant/contracts/cognitive"
"git.cin.su/m/Assistant/contracts/identity"
"git.cin.su/m/Assistant/contracts/interaction"
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
)
func TestReviewResponseUsesCognitiveGatewayAndReturnsOnlyDraftAnswer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request cognitive.GatewayAdvisoryRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode review request: %v", err)
}
if request.Message == "" || request.Meaning.SpeechAct == "" {
t.Fatalf("review request did not carry semantic context: %+v", request)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "Меня зовут Дед. Я Домовой.", FinishReason: cognitive.FinishCompleted})
}))
defer server.Close()
client, err := newCognitiveInterpretationClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
reviewed, err := client.ReviewResponse(context.Background(), identity.CreatorSession{ActorID: "actor", PrincipalID: "principal"}, "кто ты?", "Я — Домовой.", cognitive.TaskMeaning{SpeechAct: "QUESTION"})
if err != nil {
t.Fatal(err)
}
if reviewed != "Меня зовут Дед. Я Домовой." {
t.Fatalf("unexpected reviewed response %q", reviewed)
}
}
func TestReviewPreservesResearchEvidence(t *testing.T) {
draft := "Нашёл вариант. Источник: Aviasales (https://www.aviasales.ru/search)"
if reviewPreservesResearchEvidence("Нашёл вариант на Авиасейлс.", draft) {
t.Fatal("review without the direct source must be rejected")
}
if !reviewPreservesResearchEvidence("Нашёл вариант. Источник: Aviasales (https://www.aviasales.ru/search)", draft) {
t.Fatal("review with the direct source must be accepted")
}
}
func TestSimpleQuestionUsesStrictInterpretationForSemanticRouting(t *testing.T) {
result := interactiondomain.SemanticResult{
Interpretation: interaction.UtteranceInterpretation{
@@ -34,3 +77,51 @@ func TestOperationalQuestionStillUsesStrictInterpretation(t *testing.T) {
t.Fatal("operational question must retain strict interpretation routing")
}
}
func TestStructuredInventoryUsesDirectReadOnlySemanticRoute(t *testing.T) {
result := interactiondomain.SemanticResult{
Interpretation: interaction.UtteranceInterpretation{
SpeechAct: interaction.SpeechActDirective,
Applicability: interaction.SemanticApplicable,
ConfidenceMillis: 980,
},
InformationRequestKind: interaction.InformationRequestCognitiveModelInventory,
}
if shouldRequestSemanticInterpretation(result) {
t.Fatal("structured model inventory must use the direct read-only semantic route")
}
}
func TestStableReflexIsPeriodicallySampledForDrift(t *testing.T) {
result := interactiondomain.SemanticResult{Interpretation: interaction.UtteranceInterpretation{
SpeechAct: interaction.SpeechActConversational, Applicability: interaction.SemanticApplicable,
}}
found := false
for minute := 0; minute < 160; minute++ {
if shouldAuditDeterministicReflex(result, "utterance-drift-probe", time.Unix(int64(minute)*60, 0)) {
found = true
break
}
}
if !found {
t.Fatal("stable reflex was never sampled during the audit window")
}
}
func TestMeaningfulSignalsAreNotReflexOnly(t *testing.T) {
for _, act := range []interaction.SpeechAct{interaction.SpeechActQuestion, interaction.SpeechActRequestToInvestigate, interaction.SpeechActDirective} {
result := interactiondomain.SemanticResult{Interpretation: interaction.UtteranceInterpretation{SpeechAct: act, Applicability: interaction.SemanticApplicable}}
if !shouldRequestSemanticInterpretation(result) {
t.Fatalf("%s must prefer cognitive interpretation", act)
}
}
}
func TestExplicitPreferenceIsNotReinterpretedByCognitiveAudit(t *testing.T) {
result := interactiondomain.SemanticResult{Interpretation: interaction.UtteranceInterpretation{
SpeechAct: interaction.SpeechActPreference, Applicability: interaction.SemanticApplicable,
}}
if shouldRequestSemanticInterpretation(result) || shouldAuditDeterministicReflex(result, "preference", time.Unix(0, 0)) {
t.Fatal("explicit preference must remain on the deterministic durable-context path")
}
}
+407 -59
View File
@@ -30,6 +30,8 @@ import (
"log"
"net/http"
"os"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -114,7 +116,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
cognitiveClient, err := newCognitiveInterpretationClient(envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), &http.Client{Timeout: 20 * time.Second})
cognitiveClient, err := newCognitiveInterpretationClient(envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), &http.Client{Timeout: 90 * time.Second})
if err != nil {
log.Fatal(err)
}
@@ -507,14 +509,127 @@ func main() {
http.Error(w, "project focus unavailable", http.StatusServiceUnavailable)
return
}
var effectiveTaskMessage = interactiondomain.StripAssistantVocative(utterance.Content)
continuationMessage := ""
travelContinuation := false
var previousTask *interaction.HumanUtterance
var conversationHistory []interaction.HumanUtterance
if request.ConversationID != "" {
var historyErr error
conversationHistory, historyErr = interactionRepository.Utterances(r.Context(), conversation.ID)
if historyErr != nil {
log.Printf("interaction conversation history unavailable: %v", historyErr)
}
}
// Keep durable user context available even when the web client carries a
// conversation ID; a new or rotated conversation must not erase learned
// preferences.
recentUserHistory, historyErr := interactionRepository.RecentUtterancesForPrincipal(r.Context(), session.PrincipalID, 64)
if historyErr != nil {
log.Printf("interaction principal history unavailable: %v", historyErr)
} else {
log.Printf("interaction principal history loaded: %d utterances", len(recentUserHistory))
}
conversationHistory = append(conversationHistory, recentUserHistory...)
if continuation := interactiondomain.ContinueLocalSearch(effectiveTaskMessage, conversationHistory); continuation != "" {
continuationMessage = continuation
effectiveTaskMessage = continuation
} else if continuation := interactiondomain.ContinueTravelOrigin(effectiveTaskMessage, conversationHistory); continuation != "" {
continuationMessage = continuation
travelContinuation = true
effectiveTaskMessage = continuation
}
if request.ConversationID != "" && interactiondomain.IsRetryRequest(utterance.Content) {
if len(conversationHistory) > 0 {
for index := len(conversationHistory) - 1; index >= 0; index-- {
if interactiondomain.IsRetryRequest(conversationHistory[index].Content) {
continue
}
candidate := conversationHistory[index]
previousTask = &candidate
effectiveTaskMessage = interactiondomain.StripAssistantVocative(candidate.Content)
break
}
}
}
semantic, err := interactiondomain.InterpretUtterance(utterance, projects, previousFocus, now)
if err != nil {
http.Error(w, "interpret chat message", http.StatusServiceUnavailable)
return
}
if continuationMessage != "" {
continuedUtterance := utterance
continuedUtterance.Content = continuationMessage
continuedSemantic, continuedErr := interactiondomain.InterpretUtterance(continuedUtterance, projects, previousFocus, now)
if continuedErr == nil {
continuedSemantic.Interpretation.Source = "CONVERSATION_CONTINUATION"
continuedSemantic.Interpretation.SelectionReason = "location follow-up completed the previous local-search task"
semantic = continuedSemantic
}
}
if travelContinuation && interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory) == "" {
semantic.CapabilityHints = append(semantic.CapabilityHints, string(capability.CapabilityKindWebResearch))
semantic.NeedsCognition = false
semantic.Interpretation.NeedsCognition = false
}
creativeRequest := interactiondomain.IsCreativeRequest(utterance.Content) || interactiondomain.IsCreativeRequest(effectiveTaskMessage)
creativeReply := interactiondomain.CreativeDeterministicReply(utterance.Content)
if creativeReply == "" {
creativeReply = interactiondomain.CreativeDeterministicReply(effectiveTaskMessage)
}
creativeFallbackReply := interactiondomain.CreativeFallbackReply(utterance.Content)
if creativeFallbackReply == "" {
creativeFallbackReply = interactiondomain.CreativeFallbackReply(effectiveTaskMessage)
}
if creativeRequest {
semantic.CapabilityHints = interactiondomain.RemoveCapabilityHint(semantic.CapabilityHints, string(capability.CapabilityKindWebResearch))
if creativeReply != "" {
semantic.Reply = creativeReply
semantic.NeedsCognition = false
semantic.Interpretation.NeedsCognition = false
} else {
semantic.NeedsCognition = true
semantic.Interpretation.NeedsCognition = true
semantic.CapabilityHints = append(semantic.CapabilityHints, string(capability.CapabilityKindCognitiveReasoning))
}
}
locationReply := interactiondomain.LocationContextReply(utterance.Content)
if locationReply != "" {
semantic.Reply = locationReply
semantic.NeedsCognition = false
semantic.Interpretation.NeedsCognition = false
}
if previousTask != nil {
previousSemantic, previousErr := interactiondomain.InterpretUtterance(*previousTask, projects, previousFocus, now)
if previousErr == nil {
semantic = interactiondomain.CarryForwardPreviousTask(semantic, previousSemantic)
}
}
assistantName := interactiondomain.PreferredAssistantName(utterance.Content)
if assistantName == "" {
assistantName = latestAssistantName(conversationHistory)
}
identityReply := ""
log.Printf("interaction identity context: name=%q history=%d", assistantName, len(conversationHistory))
if interactiondomain.IsIdentityInquiry(utterance.Content) && assistantName != "" {
identityReply = "Меня зовут " + assistantName + ". Я Домовой, ваш помощник."
semantic.Reply = identityReply
semantic.NeedsCognition = false
semantic.CapabilityHints = nil
semantic.Interpretation.Applicability = interaction.SemanticApplicable
semantic.Interpretation.Clarification = interaction.ClarificationNotRequired
}
deterministicSpeechAct := semantic.Interpretation.SpeechAct
deterministicReply := semantic.Reply
routePlan := buildChatRoutePlan(semantic)
cognitiveAttempted := false
cognitiveAvailable := true
if shouldRequestBroaderInterpretation(semantic) {
semanticInterpretationRequested := shouldRequestSemanticInterpretation(semantic)
semanticReflexAudit := shouldAuditDeterministicReflex(semantic, string(utterance.ID), now)
if identityReply != "" {
semanticReflexAudit = false
}
if semanticInterpretationRequested || semanticReflexAudit {
cognitiveAttempted = true
projectNames := make([]string, 0, len(semantic.Projects))
for _, ref := range semantic.Projects {
@@ -522,9 +637,11 @@ func main() {
projectNames = append(projectNames, ref.ProjectDisplayName)
}
}
if response, interpretationErr := cognitiveClient.Interpret(r.Context(), buildGatewayInterpretationRequest(session, utterance, semantic, projectNames)); interpretationErr == nil && response.FinishReason == cognitive.FinishCompleted {
cognitiveUtterance := utterance
cognitiveUtterance.Content = effectiveTaskMessage
if response, interpretationErr := cognitiveClient.Interpret(r.Context(), buildGatewayInterpretationRequest(session, cognitiveUtterance, semantic, projectNames)); interpretationErr == nil && response.FinishReason == cognitive.FinishCompleted {
if candidate, candidateErr := interactionCandidateFromGateway(utterance.ID, response.Candidate, now); candidateErr == nil {
semantic = interactiondomain.ApplyCognitiveCandidate(semantic, utterance.Content, candidate, previousFocus)
semantic = interactiondomain.ApplyCognitiveCandidate(semantic, effectiveTaskMessage, candidate, previousFocus)
} else {
cognitiveAvailable = false
}
@@ -532,6 +649,14 @@ func main() {
cognitiveAvailable = false
}
}
if travelReply := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory); travelReply != "" {
semantic.Interpretation.Clarification = interaction.ClarificationRequired
semantic.Interpretation.Applicability = interaction.SemanticCandidateClarify
semantic.Reply = travelReply
deterministicReply = travelReply
semantic.NeedsCognition = false
semantic.Interpretation.NeedsCognition = false
}
if err := interactionRepository.AppendUtterance(r.Context(), conversation, utterance, intent, relations); err != nil {
http.Error(w, "save chat message", http.StatusInternalServerError)
return
@@ -543,7 +668,7 @@ func main() {
responseResult, responseErr := responseClient.Respond(r.Context(), r.Header.Get("Authorization"), continuity.HumanResponseRequest{
SourceUtteranceID: string(utterance.ID),
SourceIntentID: string(intent.ID),
Message: utterance.Content,
Message: effectiveTaskMessage,
RequestedAtUnixMillis: now.UnixMilli(),
})
if semantic.Interpretation.SpeechAct == interaction.SpeechActCorrection {
@@ -562,7 +687,7 @@ func main() {
activeFocusName = previousFocus.ProjectDisplayName
activeFocusProvenance = previousFocus.Provenance
}
var planningReply string
planningReply := deterministicReply
workPlanned := false
needsCognition := semantic.NeedsCognition
projectID := ""
@@ -591,7 +716,7 @@ func main() {
ProjectID: work.ProjectRef(projectID),
ProjectDisplayName: activeFocusName,
ProjectProvenance: work.ProjectProvenanceRef(activeFocusProvenance),
Message: utterance.Content,
Message: effectiveTaskMessage,
SpeechAct: work.SpeechActRef(semantic.Interpretation.SpeechAct),
SecondarySpeechAct: work.SpeechActRef(semantic.Interpretation.SecondarySpeechAct),
AssertionVerification: epistemics.VerificationState(semantic.Interpretation.AssertionVerification),
@@ -605,12 +730,18 @@ func main() {
helpPath = work.HelpPath{}
}
switch semantic.InformationRequestKind {
case interaction.InformationRequestMemoryInventory:
planningReply = humanMemoryInventory(conversationHistory, assistantName)
needsCognition = false
case interaction.InformationRequestTimeDifference:
planningReply = humanTimeDifferenceReply(effectiveTaskMessage)
needsCognition = false
case interaction.InformationRequestCapabilityInventory:
kinds, err := capabilityRepository.AvailableCapabilityKinds(r.Context(), session.PrincipalID)
if err == nil && len(kinds) > 0 {
planningReply = "Сейчас у меня подключены реальные capabilities: " + humanCapabilityList(kinds) + ". Для рассуждений я использую отдельный Cognitive Gateway/Router, если локальный CANARY доступен. Возможности вроде отправки почты пока будут фиксироваться как capability gap, а не как выполненное действие."
planningReply = "Я умею общаться, искать проверяемую информацию, работать с подключёнными моделями и " + humanCapabilityList(kinds) + "."
} else {
planningReply = "Я умею записывать диалог и намерения, работать с текущим контекстом, строить планы и обращаться к Cognitive Gateway при доступности локального CANARY. Реальные action-capabilities подключаются через registry; если capability нет, я фиксирую gap."
planningReply = "Я умею общаться, сохранять контекст, искать проверяемую информацию и работать с подключёнными моделями. Дополнительные действия подключаются отдельно."
}
needsCognition = false
case interaction.InformationRequestCognitiveModelInventory:
@@ -623,13 +754,36 @@ func main() {
needsCognition = false
}
researchBacked := false
if hasCapabilityHint(semantic.CapabilityHints, "WEB_RESEARCH") && semantic.Interpretation.Clarification != interaction.ClarificationRequired {
if hasChatRoute(routePlan, chatRouteResearch) && semantic.Interpretation.Clarification != interaction.ClarificationRequired {
if planID, idErr := ids.New(); idErr == nil {
result, researchErr := researchClient.SearchWeb(r.Context(), epistemics.WebSearchRequest{
Plan: epistemics.ResearchPlan{ID: epistemics.ResearchPlanID("plan:" + planID), Question: utterance.Content, SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"},
PropositionKey: "question:" + string(utterance.ID), Query: utterance.Content,
})
planningReply, researchBacked = renderResearchReply(result, researchErr)
parts := interactiondomain.IndependentRequestParts(effectiveTaskMessage)
replies := make([]string, 0, len(parts))
for index, part := range parts {
if clarification := interactiondomain.LocalSearchClarification(part); clarification != "" {
replies = append(replies, "По запросу «"+part+"»: "+clarification)
continue
}
researchCtx, cancelResearch := context.WithTimeout(r.Context(), 8*time.Second)
result, researchErr := researchClient.SearchWeb(researchCtx, epistemics.WebSearchRequest{
Plan: epistemics.ResearchPlan{ID: epistemics.ResearchPlanID(fmt.Sprintf("plan:%s:%d", planID, index)), Question: part, SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"},
PropositionKey: fmt.Sprintf("question:%s:%d", utterance.ID, index), Query: part,
})
cancelResearch()
reply, backed := renderResearchReply(part, result, researchErr)
if backed {
researchBacked = true
}
if len(parts) > 1 {
reply = "По запросу «" + part + "»: " + reply
}
replies = append(replies, reply)
}
planningReply = strings.Join(replies, "\n\n")
if researchBacked {
semantic.Reply = planningReply
}
// A failed source must not turn the complete conversation into a
// runtime failure or invite an unsupported local guess for a fact.
needsCognition = false
}
}
@@ -637,10 +791,18 @@ func main() {
planningReply = helpPath.Reply
needsCognition = false
}
if travelReply := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory); travelReply != "" {
planningReply = travelReply
needsCognition = false
}
if planningReply == "" && needsCognition && cognitiveAttempted && !cognitiveAvailable {
planningReply = humanCognitiveDegradedReply(utterance.Content)
needsCognition = false
}
if creativeFallbackReply != "" && cognitiveAttempted && !cognitiveAvailable {
planningReply = creativeFallbackReply
needsCognition = false
}
var capabilityStatus capability.CapabilityResolutionStatus
var capabilityKind capability.CapabilityKind
var capabilityGapID capability.CapabilityGapID
@@ -717,6 +879,56 @@ func main() {
planningReply = planningReply + " Для coding delegation подходящий agent implementation сейчас недоступен, поэтому я сохранил план без ложного запуска."
}
}
if identityReply != "" {
planningReply = identityReply
needsCognition = false
}
if strings.TrimSpace(planningReply) != "" {
reviewContext, cancelReview := context.WithTimeout(r.Context(), 12*time.Second)
reviewedReply, reviewErr := cognitiveClient.ReviewResponse(reviewContext, session, utterance.Content, planningReply, cognitive.TaskMeaning{
SpeechAct: string(semantic.Interpretation.SpeechAct),
CapabilityHints: toCapabilityKinds(semantic.CapabilityHints),
RequestedOutcome: semantic.Interpretation.RequestedOutcome,
InformationRequestKind: semantic.InformationRequestKind,
ConfidenceMillis: semantic.Interpretation.ConfidenceMillis,
})
cancelReview()
stableSemanticReply := identityReply != "" || creativeReply != "" || (creativeFallbackReply != "" && !cognitiveAvailable) || semantic.InformationRequestKind != "" || locationReply != "" || deterministicSpeechAct == interaction.SpeechActConversational || deterministicSpeechAct == interaction.SpeechActConfirmation || deterministicSpeechAct == interaction.SpeechActRejection
if reviewErr != nil {
log.Printf("cognitive response review unavailable: %v", reviewErr)
} else if !stableSemanticReply && (!researchBacked || reviewPreservesResearchEvidence(reviewedReply, planningReply)) {
planningReply = reviewedReply
} else if reviewedReply != planningReply {
log.Printf("cognitive response review proposed stable semantic rewrite; preserving semantic reply")
}
}
if identityReply == "" {
stableAct := interactiondomain.ClassifySpeechAct(utterance.Content, utterance.CorrectsUtteranceID)
travelClarification := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory)
if stableReply, stable := interactiondomain.StableConversationalReply(utterance.Content, stableAct); stable && travelClarification == "" {
planningReply = stableReply
needsCognition = false
}
}
if identityReply != "" {
semantic.Reply = identityReply
needsCognition = false
planningReply = identityReply
}
if creativeReply != "" {
semantic.Reply = creativeReply
needsCognition = false
planningReply = creativeReply
}
if creativeFallbackReply != "" && !cognitiveAvailable {
semantic.Reply = creativeFallbackReply
needsCognition = false
planningReply = creativeFallbackReply
}
if strings.TrimSpace(planningReply) != "" {
semantic.Reply = planningReply
}
log.Printf("chat response final message=%q act=%s deterministic=%s handled=%t fallback=%q planning=%q", utterance.Content, semantic.Interpretation.SpeechAct, deterministicSpeechAct, responseResult.Handled, semantic.Reply, planningReply)
response := interaction.ChatMessageResponse{
ConversationID: conversation.ID,
UtteranceID: utterance.ID,
@@ -1449,21 +1661,121 @@ func hasCapabilityHint(hints []string, wanted string) bool {
return false
}
func renderResearchReply(result epistemics.ResearchResult, researchErr error) (string, bool) {
func renderResearchReply(query string, result epistemics.ResearchResult, researchErr error) (string, bool) {
if researchErr != nil {
return "Я не смог получить проверяемые данные из research-источника, поэтому не буду придумывать ответ.", false
return "Сейчас не удалось получить данные из подключённого источника. Попробую снова через доступный маршрут.", false
}
lines := make([]string, 0, len(result.Findings))
for _, finding := range result.Findings {
lines := make([]string, 0, 2)
for index, finding := range result.Findings {
if finding.State != epistemics.FindingSupported || strings.TrimSpace(finding.Excerpt.Text) == "" || strings.TrimSpace(finding.Source.Locator) == "" {
continue
}
lines = append(lines, "- "+finding.Excerpt.Text+"\n Источник: "+finding.Source.Locator)
excerpt := strings.TrimSpace(finding.Excerpt.Text)
limit := 180
if index > 0 {
limit = 100
}
if len([]rune(excerpt)) > limit {
excerpt = string([]rune(excerpt)[:limit]) + "…"
}
lines = append(lines, excerpt+"\nИсточник: "+researchSourceLabel(finding))
if len(lines) == 2 {
break
}
}
if len(lines) == 0 {
return "Я не получил достаточно согласованных данных из проверяемых источников, поэтому не буду выдавать предположение как факт.", false
return "Проверяемых данных пока не найдено. Могу повторить поиск или уточнить, что именно проверить.", false
}
return "Нашёл точные выдержки из подключённого источника. Я не заменяю их догадкой:\n" + strings.Join(lines, "\n"), true
reply := strings.Join(lines, "\n")
lowerQuery := strings.ToLower(query)
if (strings.Contains(lowerQuery, "цен") || strings.Contains(lowerQuery, "стоим")) && !containsPriceEvidence(reply) {
reply = "Актуальная цена в найденных фрагментах не указана, поэтому не буду придумывать сумму. Вот страницы, где её можно проверить:\n" + reply
}
return reply, true
}
func containsPriceEvidence(value string) bool {
pricePattern := regexp.MustCompile(`(?i)(\d[\d\s.,]{2,})(?:\s*(?:₽|руб\.?|р\.?|usd|\$|€))`)
return pricePattern.MatchString(value)
}
func reviewPreservesResearchEvidence(reviewed, draft string) bool {
if strings.Contains(draft, "http://") || strings.Contains(draft, "https://") {
return strings.Contains(reviewed, "http://") || strings.Contains(reviewed, "https://")
}
if strings.Contains(draft, "Источник:") {
return strings.Contains(reviewed, "Источник:")
}
return true
}
func humanMemoryInventory(history []interaction.HumanUtterance, assistantName string) string {
rows := make([]string, 0, 4)
if assistantName != "" {
rows = append(rows, "| Имя помощника | "+assistantName+" |")
}
latestPreferenceAt := int64(-1)
latestPreference := ""
for _, utterance := range history {
message := strings.TrimSpace(utterance.Content)
lower := strings.ToLower(message)
if (strings.Contains(lower, "нравится") || strings.Contains(lower, "люблю")) && utterance.ReceivedAtUnixMillis >= latestPreferenceAt {
latestPreferenceAt = utterance.ReceivedAtUnixMillis
latestPreference = message
}
}
if latestPreference != "" {
rows = append(rows, "| Предпочтение пользователя | "+latestPreference+" |")
}
if len(rows) == 0 {
return "| Ячейка памяти | Значение |\n|---|---|\n| — | Сохранённых пользовательских предпочтений пока не найдено. |"
}
return "| Ячейка памяти | Значение |\n|---|---|\n" + strings.Join(rows, "\n")
}
func latestAssistantName(history []interaction.HumanUtterance) string {
latestAt := int64(-1)
latestName := ""
for _, utterance := range history {
if name := interactiondomain.PreferredAssistantName(utterance.Content); name != "" && utterance.ReceivedAtUnixMillis >= latestAt {
latestAt = utterance.ReceivedAtUnixMillis
latestName = name
}
}
return latestName
}
func humanTimeDifferenceReply(message string) string {
lower := strings.ToLower(message)
if strings.Contains(lower, "моск") && strings.Contains(lower, "шанх") {
return "Шанхай опережает Москву на 5 часов. Если в Москве 12:00, в Шанхае 17:00."
}
return "Уточните два города — я сравню их часовые пояса."
}
func researchSourceLabel(finding epistemics.ResearchFinding) string {
title := strings.TrimSpace(finding.Source.Title)
locator := strings.TrimSpace(finding.Source.Locator)
publisher := strings.TrimSpace(finding.Source.Publisher)
if strings.Contains(locator, "duckduckgo.com/html") || strings.Contains(locator, "bing.com/search") {
locator = ""
}
if title != "" && publisher != "" {
if locator != "" {
return publisher + " (" + locator + ")"
}
return publisher
}
if title != "" {
return title
}
if publisher != "" {
return publisher
}
if locator != "" {
return locator
}
return "подключённый источник"
}
func humanCognitiveModelInventory(candidates []capability.ResolvedCognitiveBinding, inventoryErr error) string {
@@ -1473,28 +1785,82 @@ func humanCognitiveModelInventory(candidates []capability.ResolvedCognitiveBindi
if len(candidates) == 0 {
return "В реестре пока нет подтверждённых подключённых моделей. Встроенный локальный канал Домового доступен всегда; внешние модели добавляются через настройки подключения. Ключи и пароли в чат не выводятся."
}
lines := make([]string, 0, len(candidates)+1)
lines = append(lines, "Подключённые модели из реестра:")
type inventoryEntry struct {
name string
status string
rank int
seenAt int
}
entries := make([]inventoryEntry, 0, len(candidates))
byName := make(map[string]int, len(candidates))
for _, candidate := range candidates {
modelName := string(candidate.Model.ProviderModelID)
if modelName == "" {
modelName = "имя модели не подтверждено"
continue
}
status := cognitiveInventoryStatus(candidate)
pricing := cognitiveInventoryPricing(candidate.Provider)
details := []string{status, "тип " + string(candidate.Provider.Kind), "политика " + string(candidate.Binding.DataPolicy)}
if pricing != "" {
details = append(details, pricing)
status := conciseCognitiveInventoryStatus(candidate)
rank := cognitiveInventoryRank(candidate)
if index, exists := byName[modelName]; exists {
if rank < entries[index].rank {
entries[index].status = status
entries[index].rank = rank
}
continue
}
if candidate.Provider.AuthMode != "" && candidate.Provider.AuthMode != capability.AuthNone {
details = append(details, "доступ "+humanAuthMode(candidate.Provider.AuthMode))
byName[modelName] = len(entries)
entries = append(entries, inventoryEntry{name: modelName, status: status, rank: rank, seenAt: len(entries)})
}
sort.SliceStable(entries, func(left, right int) bool {
if entries[left].rank != entries[right].rank {
return entries[left].rank < entries[right].rank
}
lines = append(lines, "- "+modelName+" ("+strings.Join(details, "; ")+")")
return entries[left].seenAt < entries[right].seenAt
})
lines := make([]string, 0, len(entries)+2)
lines = append(lines, "Доступные модели:")
unconfirmed := 0
for _, entry := range entries {
if entry.rank >= 3 {
unconfirmed++
continue
}
lines = append(lines, "- "+entry.name+" — "+entry.status)
}
if len(lines) == 1 {
return "Подтверждённых доступных моделей сейчас нет."
}
if unconfirmed > 0 {
lines = append(lines, fmt.Sprintf("Ещё %d моделей требуют проверки.", unconfirmed))
}
lines = append(lines, "Секреты, ключи и пароли намеренно не показываются.")
return strings.Join(lines, "\n")
}
func conciseCognitiveInventoryStatus(candidate capability.ResolvedCognitiveBinding) string {
if candidate.Provider.Health == capability.HealthAvailable && candidate.Model.Health == capability.HealthAvailable {
if candidate.Binding.Activation == capability.ActivationActive {
return "активна"
}
return "доступна"
}
if candidate.Provider.Health == capability.HealthDegraded || candidate.Model.Health == capability.HealthDegraded {
return "работает с ограничениями"
}
return "не подтверждена"
}
func cognitiveInventoryRank(candidate capability.ResolvedCognitiveBinding) int {
if candidate.Provider.Health == capability.HealthAvailable && candidate.Model.Health == capability.HealthAvailable {
if candidate.Binding.Activation == capability.ActivationActive {
return 0
}
return 1
}
if candidate.Provider.Health == capability.HealthDegraded || candidate.Model.Health == capability.HealthDegraded {
return 2
}
return 3
}
func cognitiveInventoryStatus(candidate capability.ResolvedCognitiveBinding) string {
if candidate.Provider.Health == capability.HealthAvailable && candidate.Model.Health == capability.HealthAvailable {
if candidate.Binding.Activation == capability.ActivationActive {
@@ -1541,44 +1907,26 @@ func humanAuthMode(mode capability.AuthMode) string {
}
func humanCognitiveChannelReply(kind interaction.InformationRequestKind, act interaction.SpeechAct, health capability.Health, healthErr error) string {
recoveryRequested := kind == interaction.InformationRequestCognitiveRecovery || act == interaction.SpeechActRequestToInvestigate || act == interaction.SpeechActDirective
if healthErr != nil {
if recoveryRequested {
return "Я не вижу подтверждённого состояния локального канала. Домовой всё равно рядом: опишите, что изменилось, и я помогу подготовить аварийную настройку."
}
return "Я не вижу подтверждённого состояния локального канала, но Домовой работает. Можно продолжать разговор и проверить канал ещё раз."
return "Не удалось проверить канал. Домовой остаётся на связи."
}
var observation string
switch health.State {
case capability.HealthAvailable:
observation = "Основной локальный канал сейчас доступен."
return "Канал мышления доступен."
case capability.HealthDegraded:
observation = "Основной локальный канал сейчас работает с ограничениями."
return "Канал мышления работает с ограничениями."
case capability.HealthUnavailable:
observation = "Основной локальный канал сейчас недоступен."
return "Канал мышления недоступен. Домовой остаётся на связи."
default:
observation = "Состояние основного локального канала пока не подтверждено."
return "Состояние канала пока не подтверждено. Домовой остаётся на связи."
}
if recoveryRequested {
if health.State == capability.HealthUnavailable {
return observation + " Домовой жив: начну с проверки встроенного маршрута, затем зафиксирую варианты аварийного восстановления без передачи секретов."
}
return observation + " Я не меняю модель прямо из разговора: сначала покажу наблюдаемое состояние и подготовлю следующий проверяемый шаг восстановления."
}
if health.State == capability.HealthUnavailable {
if recoveryRequested {
return observation + " Домовой не остановился: начну с проверки встроенного маршрута, затем зафиксирую доступные варианты восстановления без передачи секретов."
}
return observation + " Но Домовой не остановился: продолжаю принимать сообщения и помогу восстановить локальный канал."
}
return observation + " Это наблюдение о готовности capability, а не прямой доступ к модели."
}
func humanCognitiveDegradedReply(message string) string {
if strings.TrimSpace(message) == "" {
return "Основной канал мышления сейчас недоступен. Я рядом и продолжаю работать в локальном аварийном режиме."
return "Сейчас когнитивный канал недоступен; Домовой остаётся на связи."
}
return "Основной канал мышления сейчас недоступен. Домовой жив: сообщение принято, а я могу помочь разобраться с локальным каналом и настройкой восстановления."
return "Сейчас когнитивный канал недоступен. Повторю запрос через доступный маршрут, когда он восстановится."
}
func humanCapabilityList(kinds []capability.CapabilityKind) string {
+43 -3
View File
@@ -27,6 +27,36 @@ func TestBearerTokenRequiresBearerScheme(t *testing.T) {
}
}
func TestLatestAssistantNameUsesNewestDurablePreference(t *testing.T) {
history := []interaction.HumanUtterance{
{Content: "я назову тебя Дед", ReceivedAtUnixMillis: 20},
{Content: "теперь тебя зовут Фея", ReceivedAtUnixMillis: 30},
}
if got := latestAssistantName(history); got != "Фея" {
t.Fatalf("latest assistant name = %q", got)
}
}
func TestHumanMemoryInventoryUsesNewestPreference(t *testing.T) {
history := []interaction.HumanUtterance{
{Content: "мне нравится синий цвет", ReceivedAtUnixMillis: 20},
{Content: "мне нравится красный цвет", ReceivedAtUnixMillis: 30},
}
got := humanMemoryInventory(history, "Фея")
if !strings.Contains(got, "красный") || strings.Contains(got, "синий") {
t.Fatalf("memory inventory did not select newest preference: %q", got)
}
}
func TestContainsPriceEvidenceDistinguishesLinksFromPrices(t *testing.T) {
if containsPriceEvidence("RTX 4090 — market.yandex.ru") {
t.Fatal("a product link is not price evidence")
}
if !containsPriceEvidence("Цена: 189 990 ₽") {
t.Fatal("expected a currency amount to count as price evidence")
}
}
func TestCognitiveChannelQuestionUsesHumanSafeHealthReply(t *testing.T) {
reply := humanCognitiveChannelReply(interaction.InformationRequestCognitiveChannelStatus, interaction.SpeechActQuestion, capability.Health{State: capability.HealthAvailable, CapabilityID: "provider-secret-model"}, nil)
if !strings.Contains(reply, "доступен") || strings.Contains(reply, "provider-secret-model") {
@@ -36,14 +66,14 @@ func TestCognitiveChannelQuestionUsesHumanSafeHealthReply(t *testing.T) {
func TestCognitiveConfigurationRequestDoesNotPretendToMutate(t *testing.T) {
reply := humanCognitiveChannelReply(interaction.InformationRequestCognitiveRecovery, interaction.SpeechActDirective, capability.Health{State: capability.HealthUnavailable}, nil)
if !strings.Contains(reply, "Домовой жив") || !strings.Contains(reply, "аварийн") {
if reply != "Канал мышления недоступен. Домовой остаётся на связи." {
t.Fatalf("unexpected configuration reply: %q", reply)
}
}
func TestCognitiveDegradedReplyKeepsDomovoyAlive(t *testing.T) {
reply := humanCognitiveDegradedReply("Помоги настроить локальную модель")
if !strings.Contains(reply, "Домовой жив") || !strings.Contains(reply, "принято") {
if !strings.Contains(reply, "когнитивный канал") || !strings.Contains(reply, "Повторю") {
t.Fatalf("unexpected degraded reply: %q", reply)
}
}
@@ -74,11 +104,21 @@ func TestHumanCognitiveModelInventoryDoesNotExposeSecrets(t *testing.T) {
Provider: capability.ProviderInstance{Kind: "openai-compatible-chat-v1", CredentialRef: "secret-ref", AuthMode: capability.AuthKey, BillingMode: capability.BillingPerRequest, RequestCostMicros: &cost, Health: capability.HealthAvailable},
Model: capability.ModelDescriptor{ProviderModelID: "example-model", Health: capability.HealthAvailable},
}}, nil)
if strings.Contains(text, "secret-ref") || strings.Contains(text, "25") || !strings.Contains(text, "example-model") || !strings.Contains(text, "API-ключ") {
if strings.Contains(text, "secret-ref") || strings.Contains(text, "25") || !strings.Contains(text, "example-model") || !strings.Contains(text, "активна") {
t.Fatalf("unsafe or incomplete inventory: %q", text)
}
}
func TestHumanCognitiveModelInventoryDeduplicatesAndPrioritizesAvailable(t *testing.T) {
text := humanCognitiveModelInventory([]capability.ResolvedCognitiveBinding{
{Binding: capability.CognitiveBinding{Activation: capability.ActivationInactive}, Provider: capability.ProviderInstance{Health: capability.HealthUnavailable}, Model: capability.ModelDescriptor{ProviderModelID: "same-model", Health: capability.HealthUnavailable}},
{Binding: capability.CognitiveBinding{Activation: capability.ActivationActive}, Provider: capability.ProviderInstance{Health: capability.HealthAvailable}, Model: capability.ModelDescriptor{ProviderModelID: "same-model", Health: capability.HealthAvailable}},
}, nil)
if strings.Count(text, "same-model") != 1 || !strings.Contains(text, "same-model — активна") {
t.Fatalf("expected one active model entry, got %q", text)
}
}
func TestCreatorSessionHandlerAcceptsConfiguredCredentials(t *testing.T) {
handler := creatorSessionHandler("recovery", "m", "password", []byte("01234567890123456789012345678901"), func() time.Time { return time.Unix(1000, 0) })
request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", strings.NewReader(`{"username":"m","password":"password"}`))
+56 -4
View File
@@ -158,14 +158,21 @@ func TestChatMessageDoesNotExposeUnverifiedQuestionAsFact(t *testing.T) {
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
case r.Method == http.MethodPost && r.URL.Path == "/v1/creator/chat/messages":
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActQuestion, NeedsCognition: true, FallbackText: "небезопасная модельная догадка"})
_ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActQuestion, NeedsCognition: true, CapabilityHints: []capability.CapabilityKind{capability.CapabilityKindWebResearch}, FallbackText: "небезопасная модельная догадка"})
default:
t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path)
}
}))
defer core.Close()
cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("unverified question must not reach advisory: %s %s", r.Method, r.URL.Path)
if r.Method != http.MethodPost || r.URL.Path != "/v1/advisories" {
t.Fatalf("unexpected cognitive route: %s %s", r.Method, r.URL.Path)
}
var request cognitive.GatewayAdvisoryRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "Я продолжаю искать проверяемый ответ через доступный источник.", FinishReason: cognitive.FinishCompleted})
}))
defer cognitiveRuntime.Close()
handler, err := newHandler(core.URL, "http://continuity", "http://commit", core.Client(), cognitiveRuntime.URL)
@@ -184,8 +191,53 @@ func TestChatMessageDoesNotExposeUnverifiedQuestionAsFact(t *testing.T) {
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatal(err)
}
if response.Cognition != "verification_required" || strings.Contains(response.ReplyText, "небезопасная") || !strings.Contains(response.ReplyText, "не буду придумывать") {
t.Fatalf("unexpected truth-first response: %#v", response)
if response.Cognition != string(cognitive.FinishCompleted) || strings.Contains(response.ReplyText, "небезопасная") || !strings.Contains(response.ReplyText, "продолжаю искать") {
t.Fatalf("unexpected research recovery response: %#v", response)
}
}
func TestChatMessageUsesLocalAdvisoryForOrdinaryConversationalQuestion(t *testing.T) {
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/creator/sessions/current":
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
case r.Method == http.MethodPost && r.URL.Path == "/v1/creator/chat/messages":
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActQuestion, NeedsCognition: true, FallbackText: "fallback"})
default:
t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path)
}
}))
defer core.Close()
cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/advisories" {
t.Fatalf("unexpected cognitive path %s", r.URL.Path)
}
var request cognitive.GatewayAdvisoryRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "Я рядом. Давай поговорим.", FinishReason: cognitive.FinishCompleted})
}))
defer cognitiveRuntime.Close()
handler, err := newHandler(core.URL, "http://continuity", "http://commit", core.Client(), cognitiveRuntime.URL)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/chat/messages", strings.NewReader(`{"message":"Как ты?"}`))
request.Header.Set("Authorization", "Bearer session-token")
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d body=%s", recorder.Code, recorder.Body.String())
}
var response browserChatResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatal(err)
}
if response.ReplyText != "Я рядом. Давай поговорим." || response.Cognition != string(cognitive.FinishCompleted) {
t.Fatalf("unexpected conversational response: %#v", response)
}
}
+8 -1
View File
@@ -13,7 +13,14 @@ func main() {
envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"),
envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"),
envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"),
&http.Client{Timeout: 10 * time.Second},
// Core chat may spend several seconds on semantic interpretation and
// local cognition. Keep the browser gateway alive long enough to return
// the completed response instead of converting a successful core 201
// into a misleading 502.
// A cold local model may need more than one short HTTP round-trip to
// load on CPU. Keep the browser gateway aligned with the cognition
// deadline instead of turning that wait into a false unavailable reply.
&http.Client{Timeout: 90 * time.Second},
envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"),
envOr("AGENT_WORKER_URL", "http://agent-worker:8090"),
)
+20 -18
View File
@@ -468,11 +468,7 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) {
saved.NeedsCognition = false
}
}
if saved.SpeechAct == interaction.SpeechActQuestion && saved.InformationRequestKind == "" && !saved.ResearchBacked && !saved.WorkPlanned && saved.Clarification != interaction.ClarificationRequired && !asksAttention(browserRequest.Message) {
reply = truthfulVerificationRequiredReply()
cognition = "verification_required"
saved.NeedsCognition = false
} else if saved.NeedsCognition && !saved.WorkPlanned && saved.Clarification != interaction.ClarificationRequired {
if saved.NeedsCognition && !saved.WorkPlanned && saved.Clarification != interaction.ClarificationRequired {
cognition = "unavailable"
requestID, idErr := ids.New()
if idErr == nil {
@@ -514,18 +510,19 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) {
})
}
func truthfulVerificationRequiredReply() string {
return "Я не могу сейчас подтвердить этот факт по проверенному источнику, поэтому не буду придумывать ответ. Для проверки используйте официальный сайт соответствующей службы или надёжный сервис с текущими данными; когда подключён research-маршрут, Домовой сможет проверить это сам."
}
func localChatContext(saved interaction.ChatMessageResponse) []cognitive.ContextExcerpt {
if strings.TrimSpace(saved.ActiveProjectName) == "" {
return []cognitive.ContextExcerpt{}
}
bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{
DataPolicy: capability.DataPolicyLocalOnly,
MaxBytes: 2048,
Candidates: []cognitive.ContextCandidate{{
candidates := []cognitive.ContextCandidate{{
Excerpt: cognitive.ContextExcerpt{
ID: "assistant-identity",
SourceKind: "assistant-identity-policy",
SourceID: "domovoy",
Sensitivity: cognitive.SensitivityPublic,
Text: "Ты — Домовой, локальный помощник пользователя. Отвечай от имени Домового. Не называй себя Qwen, Alibaba Cloud, поставщиком модели или внутренним runtime. Если спрашивают имя, скажи: «Меня зовут Домовой». Твоя задача — понять смысл запроса, найти путь к цели и довести помощь до полезного конечного результата; не ограничивайся пересказом отказа модели.",
},
Relevance: 100,
}}
if strings.TrimSpace(saved.ActiveProjectName) != "" {
candidates = append(candidates, cognitive.ContextCandidate{
Excerpt: cognitive.ContextExcerpt{
ID: cognitive.ContextExcerptID("active-project-focus"),
SourceKind: "situation-active-project",
@@ -533,8 +530,13 @@ func localChatContext(saved interaction.ChatMessageResponse) []cognitive.Context
Sensitivity: cognitive.SensitivityPrivateAllowed,
Text: "Текущий проект: " + strings.TrimSpace(saved.ActiveProjectName),
},
Relevance: 100,
}},
Relevance: 90,
})
}
bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{
DataPolicy: capability.DataPolicyLocalOnly,
MaxBytes: 2048,
Candidates: candidates,
})
if err != nil {
return []cognitive.ContextExcerpt{}
+4 -4
View File
@@ -13,13 +13,13 @@ import (
"git.cin.su/m/Assistant/contracts/interaction"
)
func TestLocalChatContextUsesOnlyActiveProject(t *testing.T) {
func TestLocalChatContextIncludesDomovoyIdentityAndOptionalActiveProject(t *testing.T) {
context := localChatContext(interaction.ChatMessageResponse{ActiveProjectName: "Домовой"})
if len(context) != 1 || context[0].SourceKind != "situation-active-project" || context[0].Text != "Текущий проект: Домовой" {
if len(context) != 2 || context[0].SourceID != "domovoy" || !strings.Contains(context[0].Text, "Ты — Домовой") || context[1].Text != "Текущий проект: Домовой" {
t.Fatalf("unexpected local context: %#v", context)
}
if empty := localChatContext(interaction.ChatMessageResponse{}); len(empty) != 0 {
t.Fatalf("expected empty context without active project: %#v", empty)
if empty := localChatContext(interaction.ChatMessageResponse{}); len(empty) != 1 || empty[0].SourceID != "domovoy" {
t.Fatalf("identity context must remain available without active project: %#v", empty)
}
}
+10 -1
View File
@@ -89,7 +89,16 @@ func main() {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
})
searchConnector := researchadapter.SearchConnector{EndpointTemplate: os.Getenv("RESEARCH_SEARCH_URL_TEMPLATE"), AllowedHosts: allowedHosts(os.Getenv("RESEARCH_SEARCH_ALLOWED_HOSTS")), Client: &http.Client{Timeout: 8 * time.Second}, MaxResults: 3}
searchConnector := researchadapter.SearchConnector{
EndpointTemplate: os.Getenv("RESEARCH_SEARCH_URL_TEMPLATE"),
AllowedHosts: allowedHosts(os.Getenv("RESEARCH_SEARCH_ALLOWED_HOSTS")),
FallbackEndpoints: []researchadapter.SearchEndpoint{{
Template: "https://html.duckduckgo.com/html/?q=%s",
AllowedHosts: map[string]struct{}{"html.duckduckgo.com": {}},
}},
Client: &http.Client{Timeout: 8 * time.Second},
MaxResults: 3,
}
mux.HandleFunc("POST /v1/research/web/search", func(w http.ResponseWriter, r *http.Request) {
var request epistemics.WebSearchRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&request); err != nil {
+2
View File
@@ -70,6 +70,8 @@ const (
InformationRequestCognitiveModelInventory InformationRequestKind = "COGNITIVE_MODEL_INVENTORY"
InformationRequestCognitiveChannelStatus InformationRequestKind = "COGNITIVE_CHANNEL_STATUS"
InformationRequestCognitiveRecovery InformationRequestKind = "COGNITIVE_RECOVERY"
InformationRequestMemoryInventory InformationRequestKind = "MEMORY_INVENTORY"
InformationRequestTimeDifference InformationRequestKind = "TIME_DIFFERENCE"
)
type Conversation struct {
+3
View File
@@ -102,8 +102,11 @@ services:
LISTEN_ADDR: ":8083"
CORE_RUNTIME_URL: http://core-runtime:8080
LOCAL_CPU_PROVIDER_URL: http://docker.cin.su:18082/v1
LOCAL_MEDIUM_PROVIDER_URL: ${LOCAL_MEDIUM_PROVIDER_URL:-http://docker.cin.su:18082/v1}
LOCAL_MEDIUM_PROVIDER_TIMEOUT: ${LOCAL_MEDIUM_PROVIDER_TIMEOUT:-120s}
LOCAL_LIFEBOAT_PROVIDER_URL: ${LOCAL_LIFEBOAT_PROVIDER_URL:-}
LOCAL_GPU_PROVIDER_URL: http://192.168.220.91:11434
COGNITIVE_ALLOW_CANARY: "true"
COGNITIVE_OWNER_PRINCIPAL_ID: domovoy-creator-principal
ports: ["19083:8083"]
+2 -2
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/DEV-000291-mobile-acceptance-evidence.json",
"task_id": "DEV-000291",
"task_file": "dev/tasks/DEV-000294-creative-chat-routing.json",
"task_id": "DEV-000294",
"task_revision": 1
}
@@ -0,0 +1,51 @@
{
"task_id": "DEV-000292",
"task_revision": 1,
"architecture_revision": "ADR-0018",
"base_repo_revision": "HEAD",
"risk_tier": "E",
"intent": "Provide an explicitly isolated development/test runtime profile for Domovoy so local model, speech, image, OCR, Docker and diagnostic paths are usable without changing production behavior.",
"requirement_ids": ["isolated development runtime", "local multimodal model access", "development diagnostics"],
"allowed_change_scope": {
"modules": ["interaction", "core-runtime", "cognitive-worker", "creator-web", "deployment", "development-governance"],
"paths": [
"cmd/cognitive-worker/**",
"cmd/core-runtime/**",
"cmd/creator-web/**",
"cmd/research-runtime/**",
"adapters/models/**",
"adapters/research/**",
"contracts/cognitive/**",
"modules/interaction/domain/**",
"deploy/docker-compose.dev.yml",
"dev/ACTIVE_TASK.json",
"dev/tasks/DEV-000292-development-mode-runtime-access.json",
"docs/evidence/**",
"docs/PROJECT_STATUS.md"
]
},
"forbidden_change_scope": {
"paths": ["architecture/**", "security/**", "database/**", "deploy/docker-compose.prod.yml", "LLM-local/**"]
},
"allowed_architecture_delta": {
"new_cross_module_dependencies": 0,
"new_canonical_owners": 0,
"new_privileges": 1,
"new_third_party_dependencies": 0,
"new_database_owner_roles": 0
},
"contracts": {
"consume": ["existing cognitive gateway", "existing local model HTTP APIs", "existing dev compose network"],
"may_change": ["dev-only modality and diagnostic wiring; no production contract change"]
},
"semantic_owner": "development-governance owns the isolated dev profile; cognitive-worker owns model capability routing",
"canonical_state": ["existing dev databases and Docker volumes only"],
"state_transitions": ["production-only restriction -> explicit dev-only capability switch"],
"side_effects_and_privileges": ["dev network and Docker diagnostics only", "no production credentials", "no secret logging", "no production external mutation"],
"persistence_and_migrations": ["dev volumes only; no production migration"],
"failure_retry_restart": ["dev services remain independently restartable", "unsupported modality stays explicitly unavailable"],
"assumptions": ["the developer starts the profile separately from production", "test credentials are disposable and never reused in production"],
"acceptance_scenarios": ["dev profile starts independently", "text, speech, image and OCR routes are discoverable or report a precise unavailable state", "diagnostic endpoints are reachable inside the dev network", "meaningful chat requests prefer cognitive interpretation over token reflexes", "stable reflexes are sampled periodically for cognitive drift and a disagreement is observable as a cognitive candidate", "production compose remains unchanged", "no secret value appears in source or logs"],
"required_tests": ["go test ./...", "go vet ./...", "go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-architecture", "go run ./cmd/devctl independent", "docker compose -f deploy/docker-compose.dev.yml config", "git diff --check", "dev runtime health smoke"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
}
@@ -0,0 +1,44 @@
{
"task_id": "DEV-000293",
"task_revision": 1,
"architecture_revision": "ADR-0019",
"base_repo_revision": "origin/main",
"risk_tier": "E",
"intent": "Authorize a bounded read-only site-browser capability for dynamic web pages so Domovoy can observe rendered product data, including prices, without granting account access or external mutation authority.",
"requirement_ids": ["bounded browser observation", "dynamic web research", "price evidence extraction"],
"allowed_change_scope": {
"modules": ["capability", "epistemics", "core-runtime", "research"],
"paths": [
"architecture/adrs/ADR-0014-assistant-presence-attention-human-surface.md",
"architecture/adrs/ADR-0015-human-response-continuation-semantics.md",
"architecture/adrs/ADR-0019-bounded-site-browser-observation.md",
"dev/ACTIVE_TASK.json",
"dev/tasks/DEV-000293-site-browser-capability.json",
"docs/PROJECT_STATUS.md"
]
},
"forbidden_change_scope": {
"paths": ["security/**", "database/**", "deploy/docker-compose.prod.yml", "LLM-local/**"]
},
"allowed_architecture_delta": {
"new_cross_module_dependencies": 0,
"new_canonical_owners": 0,
"new_privileges": 0,
"new_third_party_dependencies": 0,
"new_database_owner_roles": 0
},
"contracts": {
"consume": ["existing capability registry", "existing epistemics observation/provenance", "existing research adapter boundary"],
"may_change": ["architecture allowance for bounded read-only browser observation"]
},
"semantic_owner": "capability owns availability and limits; epistemics owns observations and provenance",
"canonical_state": ["no new canonical state; browser output remains an observation/artifact projection"],
"state_transitions": ["search-only observation -> bounded rendered-page observation -> source-backed answer"],
"side_effects_and_privileges": ["read-only HTTPS/browser egress only", "no cookies, account sessions, credentials, downloads, uploads, form submission or external mutation"],
"persistence_and_migrations": ["no database migration"],
"failure_retry_restart": ["timeouts and blocked pages remain partial/unknown observations", "no blind retries that can duplicate external effects"],
"assumptions": ["browser runtime is replaceable and isolated from canonical runtimes", "site allowlist and navigation policy are server-owned"],
"acceptance_scenarios": ["dynamic price page can be observed when allowlisted", "401/challenge/timeout is reported as unavailable evidence", "browser cannot submit credentials or mutate a site", "source URL, observed time and coverage remain attached", "existing search-only route remains unchanged when browser capability is unavailable", "production behavior stays read-only"],
"required_tests": ["architecture preflight", "check-diff", "ADR consistency", "go test ./...", "go vet ./...", "go run ./cmd/devctl check-architecture", "go run ./cmd/devctl independent", "git diff --check"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT", "BLOCKED_BY_MISSING_CAPABILITY"]
}
@@ -0,0 +1,42 @@
{
"task_id": "DEV-000294",
"task_revision": 1,
"architecture_revision": "ADR-0019",
"base_repo_revision": "origin/main",
"risk_tier": "E",
"intent": "Route creative requests by meaning: render simple geometric sketches deterministically and send rich creative requests to cognition without web research or truth-evidence fallback.",
"requirement_ids": ["creative conversation", "no irrelevant web research", "short deterministic drawing"],
"allowed_change_scope": {
"modules": ["interaction", "core-runtime"],
"paths": [
"cmd/core-runtime/**",
"modules/interaction/domain/**",
"dev/ACTIVE_TASK.json",
"dev/tasks/DEV-000294-creative-chat-routing.json"
]
},
"forbidden_change_scope": {
"paths": ["architecture/**", "security/**", "database/**", "deploy/docker-compose.prod.yml", "LLM-local/**"]
},
"allowed_architecture_delta": {
"new_cross_module_dependencies": 0,
"new_canonical_owners": 0,
"new_privileges": 0,
"new_third_party_dependencies": 0,
"new_database_owner_roles": 0
},
"contracts": {
"consume": ["existing interaction semantic candidate", "existing cognitive gateway", "existing web research route"],
"may_change": ["creative capability hints and core route selection"]
},
"semantic_owner": "interaction owns meaning classification; core-runtime owns route orchestration",
"canonical_state": ["existing utterance and candidate history only"],
"state_transitions": ["creative request -> deterministic sketch or cognitive conversation route"],
"side_effects_and_privileges": ["no new privilege or external mutation"],
"persistence_and_migrations": ["no migration"],
"failure_retry_restart": ["creative cognition failure reports unavailable cognition; never falls back to web research"],
"assumptions": ["simple circle rendering is a text-safe projection; richer images remain a separate capability"],
"acceptance_scenarios": ["нарисуй круг returns a circle and no source links", "расскажи сказку о колобке uses cognition and no web route", "ordinary weather and price requests keep web research", "cognitive review cannot replace deterministic circle"],
"required_tests": ["go test ./...", "go vet ./...", "go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "go run ./cmd/devctl check-architecture", "go run ./cmd/devctl/independent", "git diff --check"],
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
}
+203 -7
View File
@@ -113,6 +113,8 @@ func FallbackText(act interaction.SpeechAct) string {
return "Я записал это как утверждение человека. Это не считается проверенным фактом."
case interaction.SpeechActCorrection:
return "Исправление записано отдельной репликой; история не переписана."
case interaction.SpeechActPreference:
return "Хорошо, учту это дальше."
case interaction.SpeechActQuestion, interaction.SpeechActRequestToInvestigate:
return "Когнитивный маршрут сейчас недоступен, но сообщение сохранено и может быть обработано позже."
default:
@@ -120,6 +122,54 @@ func FallbackText(act interaction.SpeechAct) string {
}
}
// PreferredAssistantName extracts a name the human explicitly assigns to the
// assistant. It is intentionally based on naming intent and nearby words, not
// on a whole-message comparison, so it remains useful with ordinary variations
// of the same request.
func PreferredAssistantName(message string) string {
tokens := semanticTokens(message)
for index, token := range tokens {
if !containsStem(token, "называ", "назов", "зов", "имен") {
continue
}
for _, candidate := range tokens[index+1:] {
if candidate == "тебя" || candidate == "тебе" || candidate == "себя" || candidate == "меня" || candidate == "в" || candidate == "теперь" || candidate == "же" {
continue
}
if candidate == "буду" || candidate == "будет" || candidate == "запомни" || candidate == "помни" || candidate == "ты" || candidate == "это" {
continue
}
if len([]rune(candidate)) < 2 || containsStem(candidate, "тогда", "дальше", "пожалуйста") {
break
}
runes := []rune(candidate)
runes[0] = []rune(strings.ToUpper(string(runes[0])))[0]
return normalizeAssistantName(string(runes))
}
}
return ""
}
func normalizeAssistantName(value string) string {
if strings.HasSuffix(value, "ю") {
return strings.TrimSuffix(value, "ю") + "я"
}
return value
}
// IsIdentityInquiry identifies a request about the assistant itself by its
// interrogative meaning. Punctuation is optional and wording may vary.
func IsIdentityInquiry(message string) bool {
tokens := semanticTokens(message)
if len(tokens) == 0 {
return false
}
if containsToken(tokens, "кто") && (containsToken(tokens, "ты") || containsToken(tokens, "вы") || containsToken(tokens, "тебя")) {
return true
}
return containsToken(tokens, "зовут") || containsToken(tokens, "имя")
}
func BuildDeterministicCandidate(message string, corrects interaction.HumanUtteranceID, millis int64) interaction.SemanticCandidate {
id, _ := ids.New()
tokens := semanticTokens(message)
@@ -213,9 +263,9 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
}
questionWords := map[string]struct{}{"кто": {}, "что": {}, "как": {}, "почему": {}, "зачем": {}, "где": {}, "когда": {}, "сколько": {}, "какой": {}, "какая": {}, "какие": {}, "чей": {}}
conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй"}
investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "подум", "понят", "посмотри"}
directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "спрос", "посчитай", "рассчитай", "ответь"}
explanatoryRequestStems := []string{"расска", "раска", "объяс", "опиш", "покаж", "подскаж"}
investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "уточн", "подум", "понят", "посмотри", "попроб", "повтор", "снов"}
directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "спрос", "найд", "посчитай", "рассчитай", "ответь"}
explanatoryRequestStems := []string{"расска", "раска", "объяс", "опиш", "покаж", "подскаж", "сообщ"}
preferenceStems := []string{"предпоч", "хочу", "лучше", "не используй"}
hypothesisStems := []string{"думаю", "кажется", "гипотез", "похоже"}
decisionStems := []string{"решил", "решение", "выбираю"}
@@ -247,6 +297,10 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
arithmeticMarker = true
}
}
if preferredNameFromTokens(tokens) {
scores[interaction.SpeechActPreference] += 8
scores[interaction.SpeechActAssertion] = 0
}
if len(tokens) >= 2 && tokens[0] == "я" && containsStemInTokens(tokens[1:], "настро", "сдела", "исправ", "удал", "созда", "перезапуст", "поменя", "добави") {
scores[interaction.SpeechActAssertion] += 5
}
@@ -300,6 +354,39 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
return scores
}
func preferredNameFromTokens(tokens []string) bool {
for index, token := range tokens {
if !containsStem(token, "называ", "назов", "зов", "имен") {
continue
}
for _, candidate := range tokens[index+1:] {
if candidate == "тебя" || candidate == "тебе" || candidate == "себя" || candidate == "меня" || candidate == "буду" || candidate == "запомни" || candidate == "помни" || candidate == "ты" || candidate == "это" {
continue
}
return len([]rune(candidate)) >= 2
}
}
return false
}
// IsRetryRequest identifies a short continuation that asks Domovoy to repeat
// the previous task. It deliberately carries no subject-specific knowledge;
// the conversation owner supplies the previous task when available.
func IsRetryRequest(message string) bool {
tokens := semanticTokens(message)
if len(tokens) == 0 || len(tokens) > 8 {
return false
}
hasRetryCue := false
for _, token := range tokens {
if containsStem(token, "повтор", "снов", "попроб", "ещ") {
hasRetryCue = true
break
}
}
return hasRetryCue
}
func topAct(scores map[interaction.SpeechAct]int) (interaction.SpeechAct, int) {
bestAct, bestScore := interaction.SpeechActUnknown, -1
for _, act := range []interaction.SpeechAct{
@@ -408,6 +495,9 @@ func capabilityHintsFor(primary, secondary interaction.SpeechAct, tokens []strin
if sourceDependentQuestion(primary, secondary, tokens) {
add("WEB_RESEARCH")
}
if isCreativeRequestTokens(tokens) {
add("COGNITIVE_REASONING")
}
if containsToken(tokens, "напомни") || containsToken(tokens, "напомин") {
add("SCHEDULE_REMINDER")
}
@@ -449,18 +539,70 @@ func capabilityHintsFor(primary, secondary interaction.SpeechAct, tokens []strin
return values
}
func isCreativeRequestTokens(tokens []string) bool {
if containsStemInTokens(tokens, "нарис", "рису", "изобраз") {
return true
}
return containsStemInTokens(tokens, "расска", "раска", "сочин", "придум") &&
containsStemInTokens(tokens, "сказк", "истор", "стих", "шутк", "песн", "басн")
}
// sourceDependentQuestion identifies semantic classes whose answer depends on
// a current or externally observed source. It intentionally uses no named
// place, person or one-off question branch; when the cue is absent the
// truth-first fallback remains in force.
func sourceDependentQuestion(primary, secondary interaction.SpeechAct, tokens []string) bool {
inquiry := primary == interaction.SpeechActQuestion || primary == interaction.SpeechActRequestToInvestigate || secondary == interaction.SpeechActQuestion || secondary == interaction.SpeechActRequestToInvestigate
inquiry := primary == interaction.SpeechActQuestion || primary == interaction.SpeechActRequestToInvestigate || secondary == interaction.SpeechActQuestion || secondary == interaction.SpeechActRequestToInvestigate || (primary == interaction.SpeechActDirective && containsStemInTokens(tokens, "найд", "поищ", "подбер"))
if !inquiry {
return false
}
currentCue := containsStemInTokens(tokens, "сейчас", "сегодня", "текущ", "актуаль", "последн", "прямо")
observedStateCue := containsStemInTokens(tokens, "погод", "температур", "осад", "ветер", "курс", "цен", "стоим", "новост", "распис", "событ", "статус", "доступен", "работает", "запущен", "срок")
return currentCue || observedStateCue
observedStateCue := containsStemInTokens(tokens, "погод", "температур", "осад", "ветер", "курс", "цен", "стоим", "новост", "распис", "событ", "статус", "доступен", "работает", "запущен", "срок") || approximateToken(tokens, "погод", 2)
placeSearchCue := containsStemInTokens(tokens, "ресторан", "кафе", "отел", "магаз", "мест", "ближ", "адрес", "организац")
travelSearchCue := containsStemInTokens(tokens, "билет", "рейс", "поезд", "автобус", "перелет", "перелёт", "отправлен", "прибыт")
return currentCue || observedStateCue || placeSearchCue || travelSearchCue
}
// approximateToken catches ordinary keyboard slips without turning routing
// into a list of complete user questions. It is only used for broad
// source-dependent concepts such as weather.
func approximateToken(tokens []string, target string, maxDistance int) bool {
for _, token := range tokens {
if editDistance([]rune(token), []rune(target)) <= maxDistance {
return true
}
}
return false
}
func editDistance(left, right []rune) int {
previous := make([]int, len(right)+1)
for index := range previous {
previous[index] = index
}
for i, leftRune := range left {
current := make([]int, len(right)+1)
current[0] = i + 1
for j, rightRune := range right {
cost := 0
if leftRune != rightRune {
cost = 1
}
current[j+1] = minInt(current[j]+1, previous[j+1]+1, previous[j]+cost)
}
previous = current
}
return previous[len(right)]
}
func minInt(values ...int) int {
result := values[0]
for _, value := range values[1:] {
if value < result {
result = value
}
}
return result
}
func informationRequestKind(message string, candidate interaction.SemanticCandidate) interaction.InformationRequestKind {
@@ -468,10 +610,16 @@ func informationRequestKind(message string, candidate interaction.SemanticCandid
if len(tokens) == 0 {
return ""
}
if containsStemInTokens(tokens, "памят", "помнишь", "ячейк") && (containsMeaningfulInterrogative(tokens) || containsStemInTokens(tokens, "вывед", "покаж", "напиш", "сообщ")) {
return interaction.InformationRequestMemoryInventory
}
if containsStemInTokens(tokens, "разниц") && containsStemInTokens(tokens, "времен", "час") {
return interaction.InformationRequestTimeDifference
}
inquiry := candidate.PrimarySpeechAct == interaction.SpeechActQuestion || candidate.PrimarySpeechAct == interaction.SpeechActRequestToInvestigate || candidate.SecondarySpeechAct == interaction.SpeechActQuestion || candidate.SecondarySpeechAct == interaction.SpeechActRequestToInvestigate
cognitiveSubject := containsStemInTokens(tokens, "модел", "ии", "ai", "локальн", "когнитив", "канал")
inventoryCue := containsStemInTokens(tokens, "список", "доступ", "подключ", "вариант") || (containsMeaningfulInterrogative(tokens) && containsStemInTokens(tokens, "настро"))
inventoryRequest := inquiry || containsStemInTokens(tokens, "напиш", "покаж", "перечисл")
inventoryRequest := inquiry || containsStemInTokens(tokens, "напиш", "покаж", "перечисл", "сообщ", "вывед", "предостав")
if cognitiveSubject && inventoryRequest && inventoryCue {
return interaction.InformationRequestCognitiveModelInventory
}
@@ -489,6 +637,48 @@ func informationRequestKind(message string, candidate interaction.SemanticCandid
return ""
}
func LocationContextReply(message string) string {
tokens := semanticTokens(message)
if len(tokens) < 4 || tokens[0] != "я" || !containsStemInTokens(tokens, "сейчас", "нахож", "жив") {
return ""
}
for index, token := range tokens {
if (token == "в" || token == "на") && index+1 < len(tokens) {
return "Запомнил: сейчас вы в " + capitalizeWord(tokens[index+1]) + "."
}
}
return ""
}
// IsCreativeRequest recognizes generation requests rather than retrieval of
// externally verifiable facts. The cognitive route remains responsible for
// the requested style and content.
func IsCreativeRequest(message string) bool {
tokens := semanticTokens(message)
return isCreativeRequestTokens(tokens)
}
// CreativeDeterministicReply covers simple geometric sketches that do not
// need a model or external source. Rich creative content remains cognitive.
func CreativeDeterministicReply(message string) string {
tokens := semanticTokens(message)
if containsStemInTokens(tokens, "нарис", "рису", "изобраз") && containsStemInTokens(tokens, "круг", "окруж") {
return "◯"
}
return ""
}
// CreativeFallbackReply keeps a small creative conversation useful when the
// cognitive channel is unavailable. It is not used when cognition returns a
// completed richer answer.
func CreativeFallbackReply(message string) string {
tokens := semanticTokens(message)
if IsCreativeRequest(message) && containsStemInTokens(tokens, "сказк", "басн") && containsStemInTokens(tokens, "колоб") {
return "Жили-были дед и баба. Испекли они Колобка, а он убежал в лес. Встретил зайца, волка и медведя — от всех укатился, но лису перехитрить не смог. Так Колобок понял: хвастовство и беспечность до добра не доводят."
}
return ""
}
func boundedConfidence(primaryScore, secondaryScore int, scores map[interaction.SpeechAct]int) int64 {
total := 0
for _, score := range scores {
@@ -668,3 +858,9 @@ func removeCapabilityHint(values []string, denied string) []string {
}
return result
}
// RemoveCapabilityHint lets orchestration boundaries suppress a route that
// contradicts an already understood semantic class.
func RemoveCapabilityHint(values []string, denied string) []string {
return removeCapabilityHint(values, denied)
}
+120
View File
@@ -1,6 +1,7 @@
package domain
import (
"strings"
"testing"
"time"
@@ -121,6 +122,8 @@ func TestInformationRequestsAreClassifiedOnceByInteraction(t *testing.T) {
{"что ты умеешь?", interaction.InformationRequestCapabilityInventory},
{"проверь состояние локального канала", interaction.InformationRequestCognitiveChannelStatus},
{"нет связи с ИИ, восстанови канал", interaction.InformationRequestCognitiveRecovery},
{"сообщии список моделей", interaction.InformationRequestCognitiveModelInventory},
{"сообщите список подключённых моделей", interaction.InformationRequestCognitiveModelInventory},
}
for _, test := range cases {
result, err := InterpretUtterance(interaction.HumanUtterance{Content: test.message}, nil, nil, time.Unix(10, 0))
@@ -152,6 +155,123 @@ func TestSourceDependentQuestionRequestsWebResearchWithoutEntityBranch(t *testin
}
}
func TestSourceDependentQuestionToleratesTyposAndPlaceSearch(t *testing.T) {
for _, message := range []string{"какая пугода в Сочи", "найди ближайший ресторан в Сочи", "найди билеты до Ялты"} {
candidate := BuildDeterministicCandidate(message, "", time.Unix(10, 0).UnixMilli())
seen := false
for _, hint := range candidate.CapabilityHints {
if hint == "WEB_RESEARCH" {
seen = true
}
}
if !seen {
t.Fatalf("message=%q was not routed to WEB_RESEARCH: %+v", message, candidate)
}
}
}
func TestRequestToClarifyCurrentFactRequestsWebResearch(t *testing.T) {
candidate := BuildDeterministicCandidate("уточни погоду в питере", "", time.Unix(10, 0).UnixMilli())
for _, hint := range candidate.CapabilityHints {
if hint == "WEB_RESEARCH" {
return
}
}
t.Fatalf("clarification request for current fact was not routed to WEB_RESEARCH: %+v", candidate)
}
func TestRetryRequestIsAContinuationCue(t *testing.T) {
for _, message := range []string{"ещё раз попробуй", "повтори", "попробуй снова"} {
if !IsRetryRequest(message) {
t.Fatalf("expected retry cue for %q", message)
}
}
}
func TestNamingPreferenceIsSemanticAndProducesNaturalAcceptance(t *testing.T) {
result := BuildDeterministicCandidate("я буду называть тебя Дед, запомни ты дед", "", time.Unix(10, 0).UnixMilli())
if result.PrimarySpeechAct != interaction.SpeechActPreference {
t.Fatalf("expected naming preference, got %+v", result)
}
if name := PreferredAssistantName("я буду называть тебя Дед, запомни ты дед"); name != "Дед" {
t.Fatalf("expected extracted assistant name, got %q", name)
}
if name := PreferredAssistantName("я назову тебя дед, запомни"); name != "Дед" {
t.Fatalf("expected conversational name form to be extracted, got %q", name)
}
if name := PreferredAssistantName("давай переименуем тебя в фею. теперь тебя зовут фея"); name != "Фея" {
t.Fatalf("rename phrase returned %q", name)
}
if !IsIdentityInquiry("ты кто?") || !IsIdentityInquiry("как тебя зовут") {
t.Fatal("identity variants should be recognized")
}
}
func TestMemoryInventoryRequestIsSemantic(t *testing.T) {
for _, message := range []string{"выведи свою память", "напиши что ты помнишь в виде таблицы"} {
candidate := BuildDeterministicCandidate(message, "", time.Unix(10, 0).UnixMilli())
if got := informationRequestKind(message, candidate); got != interaction.InformationRequestMemoryInventory {
t.Fatalf("%q: expected memory inventory, got %q", message, got)
}
}
}
func TestCreativeRequestsDoNotRouteToWebResearch(t *testing.T) {
for _, message := range []string{"нарисуй круг", "расскажи сказку о колобке"} {
candidate := BuildDeterministicCandidate(message, "", time.Now().UnixMilli())
t.Logf("%q candidate=%+v", message, candidate)
for _, hint := range candidate.CapabilityHints {
if hint == "WEB_RESEARCH" {
t.Fatalf("creative request %q unexpectedly selected web research: %+v", message, candidate)
}
}
}
}
func TestInterpretCreativeRequestDoesNotRequireExternalEvidence(t *testing.T) {
utterance := interaction.HumanUtterance{Content: "нарисуй круг"}
result, err := InterpretUtterance(utterance, nil, nil, time.Now())
if err != nil {
t.Fatal(err)
}
t.Logf("semantic result=%+v", result)
if result.Interpretation.SpeechAct == interaction.SpeechActQuestion || containsString(result.CapabilityHints, "WEB_RESEARCH") {
t.Fatalf("creative request was routed as external research: %+v", result)
}
if got := CreativeDeterministicReply("нарисуй круг"); got != "◯" {
t.Fatalf("circle reply = %q", got)
}
if !IsCreativeRequest("расскажи сказку о колобке") {
t.Fatal("story request was not recognized as creative")
}
if got := CreativeFallbackReply("расскажи сказку о колобке"); !strings.Contains(got, "Колоб") {
t.Fatalf("story fallback = %q", got)
}
}
func containsString(values []string, expected string) bool {
for _, value := range values {
if value == expected {
return true
}
}
return false
}
func TestLocationContextReply(t *testing.T) {
if got := LocationContextReply("я сейчас в Москве"); got != "Запомнил: сейчас вы в Москве." {
t.Fatalf("unexpected location reply %q", got)
}
}
func TestTimeDifferenceRequestIsSemantic(t *testing.T) {
message := "какая разница во времени между Москвой и Шанхаем?"
candidate := BuildDeterministicCandidate(message, "", time.Unix(10, 0).UnixMilli())
if got := informationRequestKind(message, candidate); got != interaction.InformationRequestTimeDifference {
t.Fatalf("expected time difference, got %q", got)
}
}
func TestDeterministicCandidateKeepsFirstPersonCompletedWorkAsAssertion(t *testing.T) {
candidate := BuildDeterministicCandidate("Я всё уже настроил.", "", time.Unix(10, 0).UnixMilli())
if candidate.PrimarySpeechAct != interaction.SpeechActAssertion {
+77
View File
@@ -136,6 +136,42 @@ func ApplyCognitiveCandidate(result SemanticResult, message string, candidate in
return result
}
// CarryForwardPreviousTask transfers only the routing meaning of a previous
// user task to a short retry utterance. The new utterance keeps its own IDs
// and is persisted separately, while the caller may use the previous content
// as the effective task input for an adapter or capability.
func CarryForwardPreviousTask(current, previous SemanticResult) SemanticResult {
if previous.Interpretation.Clarification == interaction.ClarificationRequired ||
previous.Interpretation.SpeechAct == interaction.SpeechActAssertion ||
previous.Interpretation.SpeechAct == interaction.SpeechActUnknown {
return current
}
current.Interpretation.SpeechAct = previous.Interpretation.SpeechAct
current.Interpretation.SecondarySpeechAct = previous.Interpretation.SecondarySpeechAct
current.Interpretation.ConfidenceMillis = previous.Interpretation.ConfidenceMillis
current.Interpretation.Source = "CONVERSATION_CONTINUATION"
current.Interpretation.AssertionVerification = previous.Interpretation.AssertionVerification
current.Interpretation.RequestedOutcome = previous.Interpretation.RequestedOutcome
current.Interpretation.Clarification = previous.Interpretation.Clarification
current.Interpretation.NeedsCognition = previous.Interpretation.NeedsCognition
current.Interpretation.Applicability = previous.Interpretation.Applicability
current.Interpretation.SelectionReason = "short retry carries forward the previous user task"
current.NeedsCognition = previous.NeedsCognition
current.CapabilityHints = append([]string(nil), previous.CapabilityHints...)
current.InformationRequestKind = previous.InformationRequestKind
if len(current.Candidates) > 0 && len(previous.Candidates) > 0 {
candidate := previous.Candidates[0]
candidate.ID = current.Candidates[0].ID
candidate.UtteranceID = current.Interpretation.UtteranceID
candidate.Source = interaction.CandidateSourceCognitive
candidate.SelectionState = interaction.CandidateSelected
candidate.RecordedAtUnixMillis = current.Interpretation.RecordedAtUnixMillis
current.Candidates[0] = candidate
}
current.Reply = "Повторяю предыдущую задачу."
return current
}
func explicitProjectFocus(message string, projects []situation.ProjectDescriptor) *interaction.ProjectReference {
lower := strings.ToLower(message)
if !(strings.Contains(lower, "работаем с") || strings.Contains(lower, "вернемся к") || strings.Contains(lower, "вернёмся к") || strings.HasPrefix(strings.TrimSpace(lower), "теперь ")) {
@@ -271,6 +307,11 @@ func semanticReply(message string, result SemanticResult, previousFocus *situati
return "Фокус переключён: " + result.ActiveFocus.ProjectDisplayName + "."
}
switch result.Interpretation.SpeechAct {
case interaction.SpeechActPreference:
if name := PreferredAssistantName(message); name != "" {
return "Хорошо, зови меня " + name + "."
}
return "Хорошо, учту это дальше."
case interaction.SpeechActAssertion:
if result.Interpretation.Applicability == interaction.SemanticNeedsBroaderInterpret || result.Interpretation.Applicability == interaction.SemanticLowConfidence {
return "Смысл сообщения пока понят недостаточно надёжно. Я сохраню его и при доступности более широкого когнитивного разбора не буду притворяться, что это уже проверенный факт."
@@ -288,6 +329,19 @@ func semanticReply(message string, result SemanticResult, previousFocus *situati
return "Исправление записано отдельно, и я вижу здесь ещё новую рабочую просьбу; история сохранена без переписывания."
}
return "Исправление записано новым сообщением; прежняя история сохранена."
case interaction.SpeechActConversational:
lower := strings.ToLower(message)
if strings.Contains(lower, "спасибо") || strings.Contains(lower, "благодар") {
return "Пожалуйста."
}
if strings.Contains(lower, "привет") || strings.Contains(lower, "здравств") {
return "Здравствуйте."
}
return "Я рядом."
case interaction.SpeechActConfirmation:
return "Хорошо."
case interaction.SpeechActRejection:
return "Понял."
case interaction.SpeechActQuestion:
lower := strings.ToLower(message)
if previousFocus != nil && (strings.Contains(lower, "сейчас") || strings.Contains(lower, "что мы") || strings.Contains(lower, "делаем")) {
@@ -304,6 +358,29 @@ func semanticReply(message string, result SemanticResult, previousFocus *situati
return FallbackText(result.Interpretation.SpeechAct)
}
// StableConversationalReply returns the small set of low-risk conversational
// replies whose meaning must remain owned by interaction even after a sampled
// cognitive audit or a capability fast path.
func StableConversationalReply(message string, act interaction.SpeechAct) (string, bool) {
switch act {
case interaction.SpeechActConversational:
lower := strings.ToLower(message)
if strings.Contains(lower, "спасибо") || strings.Contains(lower, "благодар") {
return "Пожалуйста.", true
}
if strings.Contains(lower, "привет") || strings.Contains(lower, "здравств") {
return "Здравствуйте.", true
}
return "Я рядом.", true
case interaction.SpeechActConfirmation:
return "Хорошо.", true
case interaction.SpeechActRejection:
return "Понял.", true
default:
return "", false
}
}
func isCurrentFocusQuestion(message string, act interaction.SpeechAct) bool {
if act != interaction.SpeechActQuestion {
return false
@@ -33,6 +33,117 @@ func TestInterpretUtteranceAssertionIsNotVerifiedFact(t *testing.T) {
}
}
func TestInterpretUtteranceNamingPreferenceIsAccepted(t *testing.T) {
result := interpretForTest(t, "я буду называть тебя Дед, запомни ты дед", nil, "")
if result.Interpretation.SpeechAct != interaction.SpeechActPreference {
t.Fatalf("expected preference, got %s", result.Interpretation.SpeechAct)
}
if result.Reply != "Хорошо, зови меня Дед." {
t.Fatalf("unexpected preference reply: %q", result.Reply)
}
}
func TestInterpretUtteranceThanksHasNaturalConversationalReply(t *testing.T) {
result := interpretForTest(t, "спасибо", nil, "")
if result.Interpretation.SpeechAct != interaction.SpeechActConversational {
t.Fatalf("expected conversational act, got %s", result.Interpretation.SpeechAct)
}
if result.Reply != "Пожалуйста." {
t.Fatalf("unexpected thanks reply: %q", result.Reply)
}
}
func TestStableConversationalReplySurvivesCognitiveRewrite(t *testing.T) {
reply, stable := StableConversationalReply("спасибо", interaction.SpeechActConversational)
if !stable || reply != "Пожалуйста." {
t.Fatalf("unexpected stable conversational reply: stable=%t reply=%q", stable, reply)
}
}
func TestStripAssistantVocative(t *testing.T) {
if got := StripAssistantVocative("Дед, какая погода в Сочи?"); got != "какая погода в Сочи?" {
t.Fatalf("unexpected cleaned query: %q", got)
}
if got := StripAssistantVocative("домовой: найди ресторан"); got != "найди ресторан" {
t.Fatalf("unexpected cleaned query: %q", got)
}
}
func TestIndependentRequestParts(t *testing.T) {
parts := IndependentRequestParts("Дед, узнай погоду в Сочи и найди ресторан")
if len(parts) != 2 || parts[0] != "узнай погоду в Сочи" || parts[1] != "найди ресторан" {
t.Fatalf("unexpected request parts: %#v", parts)
}
}
func TestLocalSearchClarification(t *testing.T) {
if got := LocalSearchClarification("найди ближайший ресторан"); got == "" {
t.Fatal("proximity search without place must ask for location")
}
if got := LocalSearchClarification("найди ближайший ресторан в Сочи"); got != "" {
t.Fatalf("explicit place must proceed, got %q", got)
}
}
func TestContinueLocalSearch(t *testing.T) {
history := []interaction.HumanUtterance{{Content: "Найди ближайший ресторан"}}
if got := ContinueLocalSearch("в Сочи", history); got != "Найди ближайший ресторан в Сочи" {
t.Fatalf("unexpected continuation: %q", got)
}
if got := ContinueLocalSearch("в Сочи", nil); got != "" {
t.Fatalf("location must not create a goal without conversation context: %q", got)
}
}
func TestContinueTravelOrigin(t *testing.T) {
history := []interaction.HumanUtterance{{Content: "Как можно добраться до Сочи?"}}
if got := ContinueTravelOrigin("я в Казани", history); got != "Как можно добраться до Сочи из Казани" {
t.Fatalf("unexpected travel continuation: %q", got)
}
if got := ContinueTravelOrigin("я в Казани", nil); got != "" {
t.Fatalf("location must not create a travel task without context: %q", got)
}
if got := ContinueTravelOrigin("Москва", history); got != "Как можно добраться до Сочи из города Москва" {
t.Fatalf("bare city should complete travel origin: %q", got)
}
}
func TestTravelSearchClarification(t *testing.T) {
if got := TravelSearchClarification("Найди билеты", nil); got == "" {
t.Fatal("underspecified travel request must ask for missing route and date")
}
if got := TravelSearchClarification("Покажи ближайшие рейсы до Сочи", nil); got != "Из какого города вылетать?" {
t.Fatalf("nearest-flight request should default date to now, got %q", got)
}
if got := TravelSearchClarification("Найди билеты из Москвы в Сочи на 12 сентября", nil); got != "" {
t.Fatalf("complete travel request must proceed, got %q", got)
}
history := []interaction.HumanUtterance{{Content: "Найди билеты"}}
if got := TravelSearchClarification("а куда летим?", history); got == "" {
t.Fatal("follow-up must remain attached to the travel goal")
}
if got := TravelSearchClarification("какая погода в Сочи?", history); got != "" {
t.Fatalf("unrelated question must not inherit travel goal, got %q", got)
}
if got := TravelSearchClarification("Как можно добраться до Сочи?", nil); got != "Из какого города выезжать в Сочи? Подберу самолёт, поезд, автобус или машину." {
t.Fatalf("general travel question should ask departure city, got %q", got)
}
if got := TravelSearchClarification("Как можно добраться до Сочи из Казани", nil); got != "" {
t.Fatalf("complete general travel route must proceed, got %q", got)
}
contextHistory := []interaction.HumanUtterance{{Content: "Как можно добраться до Сочи?"}}
if got := TravelSearchClarification("Найди билеты, какой ближайший самолёт", contextHistory); got != "Из какого города вылетать в Сочи?" {
t.Fatalf("travel destination should be inherited from context, got %q", got)
}
noisyContext := []interaction.HumanUtterance{
{Content: "Как можно добраться до Сочи?"},
{Content: "Какая погода в Питере?"},
}
if got := TravelSearchClarification("Найди билеты", noisyContext); got != "Из какого города вылетать в Сочи и на какую дату?" {
t.Fatalf("unrelated weather history must not replace travel destination, got %q", got)
}
}
func TestInterpretUtteranceDirectiveRecordsOutcomeWithoutExecution(t *testing.T) {
result := interpretForTest(t, "Поменяй базу на УПО1.", activeFocus("upo", "УПО"), "")
if result.Interpretation.SpeechAct != interaction.SpeechActDirective {
@@ -136,6 +247,30 @@ func TestInterpretUtterancePunctuationFreeArithmeticStillRequestsCognition(t *te
}
}
func TestCarryForwardPreviousTaskPreservesRoutingForShortRetry(t *testing.T) {
current := SemanticResult{
Interpretation: interaction.UtteranceInterpretation{UtteranceID: "retry", SpeechAct: interaction.SpeechActAssertion},
Candidates: []interaction.SemanticCandidate{{ID: "current", UtteranceID: "retry"}},
}
previous := SemanticResult{
Interpretation: interaction.UtteranceInterpretation{
UtteranceID: "previous",
SpeechAct: interaction.SpeechActRequestToInvestigate,
RequestedOutcome: "узнать текущую погоду",
NeedsCognition: false,
Applicability: interaction.SemanticApplicable,
Clarification: interaction.ClarificationNotRequired,
RecordedAtUnixMillis: 100,
},
Candidates: []interaction.SemanticCandidate{{ID: "previous-candidate", PrimarySpeechAct: interaction.SpeechActRequestToInvestigate, CapabilityHints: []string{"WEB_RESEARCH"}}},
CapabilityHints: []string{"WEB_RESEARCH"},
}
result := CarryForwardPreviousTask(current, previous)
if result.Interpretation.SpeechAct != interaction.SpeechActRequestToInvestigate || result.CapabilityHints[0] != "WEB_RESEARCH" || result.Candidates[0].UtteranceID != "retry" {
t.Fatalf("previous routing was not carried forward: %#v", result)
}
}
func TestInterpretUtteranceModalExplanationRequestDefersToCognition(t *testing.T) {
for _, message := range []string{"можешь расказать об утре", "расскажи об утре", "про утро расскажешь"} {
result := interpretForTest(t, message, nil, "")
+288
View File
@@ -0,0 +1,288 @@
package domain
import (
"strings"
"git.cin.su/m/Assistant/contracts/interaction"
)
// StripAssistantVocative removes a leading assistant name used as an address.
// The original utterance remains the historical source; this value is only for
// interpretation, cognitive prompts and external queries.
func StripAssistantVocative(message string) string {
trimmed := strings.TrimSpace(message)
lower := strings.ToLower(trimmed)
for _, name := range []string{"дед", "домовой"} {
if !strings.HasPrefix(lower, name) {
continue
}
rest := trimmed[len(name):]
if rest == "" || !strings.ContainsRune(" \t,;:!?—-", []rune(rest)[0]) {
continue
}
return strings.TrimSpace(strings.TrimLeft(rest, " \t,;:!?—-"))
}
return trimmed
}
// IndependentRequestParts keeps separate user actions visible to the
// orchestrator. It is intentionally limited to the ordinary coordinating
// conjunction; the cognitive route remains responsible for ambiguous prose.
func IndependentRequestParts(message string) []string {
cleaned := StripAssistantVocative(message)
parts := strings.Split(cleaned, " и ")
if len(parts) < 2 {
return []string{cleaned}
}
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
if len(result) < 2 {
return []string{cleaned}
}
return result
}
// LocalSearchClarification prevents a proximity search from silently using a
// default city when the user did not provide a place.
func LocalSearchClarification(message string) string {
tokens := semanticTokens(StripAssistantVocative(message))
if !containsStemInTokens(tokens, "ресторан", "кафе") || !containsStemInTokens(tokens, "ближ", "рядом") {
return ""
}
for index, token := range tokens {
if token != "в" && token != "на" && token != "около" && !containsStem(token, "район", "город") {
continue
}
if index+1 < len(tokens) || containsStem(token, "район", "город") {
return ""
}
}
return "В каком городе или районе искать ресторан?"
}
// ContinueLocalSearch turns a location-only follow-up into the previous local
// search request, but only when that request is present in this conversation.
func ContinueLocalSearch(message string, history []interaction.HumanUtterance) string {
current := strings.TrimSpace(StripAssistantVocative(message))
tokens := semanticTokens(current)
if len(tokens) == 0 || len(tokens) > 3 || (tokens[0] != "в" && tokens[0] != "на" && tokens[0] != "около") {
return ""
}
var latest *interaction.HumanUtterance
for index := range history {
candidate := history[index]
candidateText := strings.TrimSpace(StripAssistantVocative(candidate.Content))
candidateTokens := semanticTokens(candidateText)
if !containsStemInTokens(candidateTokens, "ресторан", "кафе") || !containsStemInTokens(candidateTokens, "ближ", "рядом") {
continue
}
if latest == nil || candidate.ReceivedAtUnixMillis > latest.ReceivedAtUnixMillis {
latest = &candidate
}
}
if latest != nil {
previous := strings.TrimSpace(StripAssistantVocative(latest.Content))
return strings.TrimSpace(previous + " " + current)
}
return ""
}
// ContinueTravelOrigin turns a short location answer into the previous travel
// task. The original utterance remains history; only the effective task is
// completed for routing and research.
func ContinueTravelOrigin(message string, history []interaction.HumanUtterance) string {
current := semanticTokens(StripAssistantVocative(message))
if len(current) == 0 || len(current) > 6 {
return ""
}
location := ""
if current[0] == "я" {
if len(current) < 3 {
return ""
}
for index, token := range current {
if (token == "в" || token == "из") && index+1 < len(current) {
location = capitalizeWord(current[index+1])
break
}
}
} else if len(current) <= 2 && !isTravelDateWord(current[0]) && len([]rune(current[0])) >= 3 {
location = capitalizeWord(current[0])
}
if location == "" {
return ""
}
var latest *interaction.HumanUtterance
for index := range history {
candidate := history[index]
if !travelContextCue(semanticTokens(candidate.Content)) {
continue
}
if latest == nil || candidate.ReceivedAtUnixMillis > latest.ReceivedAtUnixMillis {
latest = &candidate
}
}
if latest == nil {
return ""
}
previous := strings.TrimRight(strings.TrimSpace(StripAssistantVocative(latest.Content)), "?!.,;:")
preposition := " из "
if current[0] != "я" {
preposition = " из города "
}
return strings.TrimSpace(previous + preposition + location)
}
// TravelSearchClarification keeps an incomplete travel request in dialogue
// instead of reducing it to an unrelated generic web search.
func TravelSearchClarification(message string, history []interaction.HumanUtterance) string {
current := semanticTokens(StripAssistantVocative(message))
travel := travelCue(current)
if !travel {
travel = containsStemInTokens(current, "добрат", "передвиг", "поех", "дорог")
}
if !travel && travelFollowUpCue(current) {
for index := len(history) - 1; index >= 0; index-- {
if travelCue(semanticTokens(history[index].Content)) {
travel = true
break
}
}
}
if !travel {
return ""
}
origin := containsStemInTokens(current, "из", "откуда", "от")
destination := containsStemInTokens(current, "в", "куда", "до")
date := containsStemInTokens(current, "сегодня", "завтра", "послезавтр", "январ", "феврал", "март", "апрел", "мая", "июн", "июл", "август", "сентябр", "октябр", "ноябр", "декабр")
if containsStemInTokens(current, "ближ") {
date = true
}
if !date {
for _, token := range current {
if hasDigit(token) {
date = true
break
}
}
}
if origin && destination && date {
return ""
}
ticketRequest := containsStemInTokens(current, "билет", "рейс", "самолет", "самолёт", "вылет")
if !ticketRequest && origin && destination {
return ""
}
if !destination {
if contextDestination := travelContextDestination(history); contextDestination != "" {
destination = true
if !origin && !date {
return "Из какого города вылетать в " + contextDestination + " и на какую дату?"
}
if !origin {
return "Из какого города вылетать в " + contextDestination + "?"
}
}
}
if !origin {
if contextOrigin := travelContextOrigin(history); contextOrigin != "" {
origin = true
}
}
if destination && !origin && !date {
if !ticketRequest {
return "Из какого города выезжать в " + destinationLabel(current, history) + "? Подберу самолёт, поезд, автобус или машину."
}
return "Из какого города вылетать и на какую дату?"
}
if destination && !origin {
return "Из какого города вылетать?"
}
if destination && !date {
return "На какую дату нужны билеты?"
}
return "Уточните маршрут и дату: откуда, куда и на какой день нужны билеты?"
}
func isTravelDateWord(value string) bool {
return containsStem(value, "сегодня", "завтра", "послезавтр", "январ", "феврал", "март", "апрел", "мая", "июн", "июл", "август", "сентябр", "октябр", "ноябр", "декабр")
}
func destinationLabel(current []string, history []interaction.HumanUtterance) string {
for index, token := range current {
if (token == "в" || token == "до") && index+1 < len(current) {
return capitalizeWord(current[index+1])
}
}
if destination := travelContextDestination(history); destination != "" {
return destination
}
return "пункт назначения"
}
func travelContextDestination(history []interaction.HumanUtterance) string {
for index := len(history) - 1; index >= 0; index-- {
tokens := semanticTokens(history[index].Content)
if !travelContextCue(tokens) {
continue
}
for tokenIndex, token := range tokens {
if token != "в" && token != "до" {
continue
}
if tokenIndex+1 < len(tokens) && len([]rune(tokens[tokenIndex+1])) >= 3 {
return capitalizeWord(tokens[tokenIndex+1])
}
}
}
return ""
}
func travelContextOrigin(history []interaction.HumanUtterance) string {
for index := len(history) - 1; index >= 0; index-- {
tokens := semanticTokens(history[index].Content)
if len(tokens) < 3 || tokens[0] != "я" || !containsStemInTokens(tokens, "сейчас", "нахож", "жив") {
continue
}
for tokenIndex, token := range tokens {
if (token == "в" || token == "из") && tokenIndex+1 < len(tokens) {
return capitalizeWord(tokens[tokenIndex+1])
}
}
}
return ""
}
func travelContextCue(tokens []string) bool {
return travelCue(tokens) || containsStemInTokens(tokens, "добрат", "передвиг", "ехать", "едем", "лететь", "поездк")
}
func capitalizeWord(value string) string {
runes := []rune(value)
if len(runes) == 0 {
return value
}
return strings.ToUpper(string(runes[0])) + string(runes[1:])
}
func travelCue(tokens []string) bool {
return containsStemInTokens(tokens, "билет", "рейс", "поезд", "автобус", "перелет", "перелёт")
}
func travelFollowUpCue(tokens []string) bool {
return containsStemInTokens(tokens, "куда", "откуда", "маршрут", "летим", "дата", "число")
}
func hasDigit(value string) bool {
for _, runeValue := range value {
if runeValue >= '0' && runeValue <= '9' {
return true
}
}
return false
}
+9 -2
View File
@@ -170,7 +170,7 @@ func addScenarioPlan(input work.PlanningInput, millis int64, candidate *work.Pla
addRequirement(candidate, capability.CapabilityKindSendEmail, "send email only after authority", epistemics.VerificationNotApplicable, millis)
addStep(candidate, work.PlanStepPrepare, "Resolve recipient and appropriate communication channel.", 1, millis)
addStep(candidate, work.PlanStepCommunicate, "Prepare the message for a communication adapter; do not send without authority.", 2, millis)
case containsAny(lower, "тормозит", "медлен", "производительн", "разберись", "почему"):
case technicalInvestigationRequest(lower):
addRequirement(candidate, capability.CapabilityKindInspectProject, "inspect project context", epistemics.VerificationNotApplicable, millis)
addRequirement(candidate, capability.CapabilityKindReadDatabase, "collect database performance observations", epistemics.VerificationUnknown, millis)
addRequirement(candidate, capability.CapabilityKindQueryLogs, "collect runtime/log observations", epistemics.VerificationUnknown, millis)
@@ -208,7 +208,14 @@ func addScenarioPlan(input work.PlanningInput, millis int64, candidate *work.Pla
}
func requiresDurableWork(lower string) bool {
return containsAny(lower, "тормозит", "медлен", "производительн", "разберись", "почему", "добавь", "поддержк", "возможность", "реализ", "патч", "исправь", "установи", "большую модель")
return technicalInvestigationRequest(lower) || containsAny(lower, "добавь", "поддержк", "возможность", "реализ", "патч", "исправь", "установи", "большую модель")
}
func technicalInvestigationRequest(lower string) bool {
if containsAny(lower, "тормозит", "медлен", "производительн") {
return true
}
return containsAny(lower, "разберись", "почему", "проанализ") && containsAny(lower, "код", "проект", "сервис", "прилож", "ошиб", "лог", "база", "сервер", "модул", "упо")
}
func addRequirement(candidate *work.PlanCandidate, kind capability.CapabilityKind, purpose string, precondition epistemics.VerificationState, millis int64) {
+7
View File
@@ -99,6 +99,13 @@ func TestHelpPathComplexWorkMaterializesDurablePlanning(t *testing.T) {
}
}
func TestOrdinaryWhyQuestionDoesNotBecomeTechnicalWork(t *testing.T) {
path := DetermineHelpPath(planningInputForTest("Почему небо голубое?", "QUESTION", epistemics.VerificationNotApplicable))
if path.NeedsDurableGoal || path.NeedsDurableWork || path.NeedsDurablePlan || path.Kind == work.HelpPathDurableWork {
t.Fatalf("ordinary knowledge question took %s path: %+v", path.Kind, path)
}
}
func TestHelpPathCapabilityGapDoesNotBecomeCodexTask(t *testing.T) {
path := DetermineHelpPath(planningInputForTest("Подключи интеграцию с неизвестным сервисом, которого сейчас нет.", "DIRECTIVE", epistemics.VerificationNotApplicable))
if path.Kind != work.HelpPathCapabilityGap || !path.CapabilityGap {