Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
342a93caa4 | ||
|
|
0904a4ade7 | ||
|
|
bbd525eeb5 | ||
|
|
352703a91f | ||
|
|
5a439f7ba8 | ||
|
|
f7200b0704 | ||
|
|
14bf8261da | ||
|
|
feb87d5c4e | ||
|
|
24320f22a6 | ||
|
|
98cf218cc3 | ||
|
|
7abe73e7bd | ||
|
|
a05ddacff3 | ||
|
|
20b18de60a | ||
|
|
d5c98dd62f | ||
|
|
61d08fa05a | ||
|
|
1ea2e713d8 | ||
|
|
38dce57666 | ||
|
|
8f255b8bde | ||
|
|
6b97d6668b | ||
|
|
3fa1d70379 | ||
|
|
2dd05a7aa6 | ||
|
|
02227edc23 | ||
|
|
a2297a84ad | ||
|
|
05cbd8adc7 | ||
|
|
984731b111 | ||
|
|
0da03c18d1 | ||
|
|
302b8fc8cd | ||
|
|
59239635cd | ||
|
|
238105a7da | ||
|
|
d7ca5f7e68 | ||
|
|
827135a02d | ||
|
|
a3ddf53207 | ||
|
|
35ac9015ad | ||
|
|
1c196ef675 | ||
|
|
2ce3b5a9b3 | ||
|
|
7421fd058d | ||
|
|
368e9df551 | ||
|
|
e9629046cb | ||
|
|
b5ccc419f6 | ||
|
|
615723da2a | ||
|
|
d1bcd5beff | ||
|
|
519dc583a0 | ||
|
|
0bf883cf56 | ||
|
|
c8502c735c | ||
|
|
48af97fbdd | ||
|
|
13bcf87ea5 | ||
|
|
e271fbb1e1 | ||
|
|
5678686c57 | ||
|
|
9fc3fd1aa5 | ||
|
|
e3a9c18cb3 | ||
|
|
9534b041db | ||
|
|
9426fa40a9 | ||
|
|
51e2b23082 | ||
|
|
aece914fb8 | ||
|
|
762e312730 | ||
|
|
e9eb529d6e | ||
|
|
e39a2b7dda | ||
|
|
cdc6baa4c0 | ||
|
|
255ab8dbf3 | ||
|
|
a76dc42bd8 | ||
|
|
f0cbd71129 | ||
|
|
aa10c3e093 | ||
|
|
0d836121d0 | ||
|
|
6e8ecbf5e5 | ||
|
|
8088ff197e | ||
|
|
55ee967f2f | ||
|
|
a56bd614bd | ||
|
|
992e8470b2 | ||
|
|
fe2bd8493f | ||
|
|
e43820c5ce | ||
|
|
662e30cb93 | ||
|
|
a51675e58d | ||
|
|
820b1dc643 | ||
|
|
94f2ae81c3 | ||
|
|
9020100313 | ||
|
|
d097940692 | ||
|
|
28dfcc8aec | ||
|
|
7dd0c8e7a4 | ||
|
|
bed21ad5c0 | ||
|
|
2622140529 | ||
|
|
3f9dc7a270 | ||
|
|
b5633c7fab | ||
|
|
5d053fedf4 | ||
|
|
d924694602 | ||
|
|
cf8a48ae0d | ||
|
|
24e865aa07 | ||
|
|
5d0dbbbe92 | ||
|
|
f2277e35b4 | ||
|
|
e8a642c5e2 | ||
|
|
399bdea220 | ||
|
|
9a0205758b | ||
|
|
0507e1af9c | ||
|
|
f893744ed1 | ||
|
|
e455e89777 | ||
|
|
9b60da96b0 | ||
|
|
34c0e7a0f4 | ||
|
|
550ce99bcd | ||
|
|
e81411c4a4 | ||
|
|
688a7e49db | ||
|
|
2ce997ca73 | ||
|
|
075967c395 | ||
|
|
1efd8bd25d | ||
|
|
e2bfd83481 | ||
|
|
2d5f0c69d0 | ||
|
|
339159a194 | ||
|
|
ad905e0bc5 | ||
|
|
721817515a | ||
|
|
73de357c9f | ||
|
|
b1e635d00b | ||
|
|
e489b5511a | ||
|
|
79409fb5c8 | ||
|
|
e9bfe12293 | ||
|
|
0833a7a0ca | ||
|
|
dfecab7c34 | ||
|
|
2eb57675c8 | ||
|
|
98f1454b3c | ||
|
|
d274768bcc | ||
|
|
ba31326302 | ||
|
|
f4e674fa9b | ||
|
|
1c7447b7e3 | ||
|
|
2d24c011aa | ||
|
|
b9b082b802 | ||
|
|
74dd9922a5 | ||
|
|
4298b63d91 | ||
|
|
c48fba7d8d | ||
|
|
7b71216a05 | ||
|
|
35204177e9 | ||
|
|
fc39278c91 | ||
|
|
eefd54137c | ||
|
|
880cd055dd | ||
|
|
ce07163541 | ||
|
|
6bd9acac6e | ||
|
|
1be14da122 | ||
|
|
96a61a46d0 | ||
|
|
4f4e43c953 | ||
|
|
306a35e294 | ||
|
|
53a0bdb2e3 | ||
|
|
bf73dcd1ea | ||
|
|
6409a9d442 | ||
|
|
96f96bd130 | ||
|
|
a8ce97c807 | ||
|
|
0804a69b4f | ||
|
|
e816b70f46 | ||
|
|
8adcd86cad | ||
|
|
9d2db45dd5 | ||
|
|
111276fb22 | ||
|
|
cad4623232 | ||
|
|
ececb58025 | ||
|
|
4a9ba3b7d4 | ||
|
|
b029adfb43 | ||
|
|
d0651474ab | ||
|
|
ca9db836de | ||
|
|
44ea97b938 | ||
|
|
84f5ab6c47 | ||
|
|
153372c2de | ||
|
|
7384b8922b | ||
|
|
295523b470 | ||
|
|
0991a24739 | ||
|
|
420c52b10e | ||
|
|
1e3ed210d7 | ||
|
|
947a75b771 | ||
|
|
c84ddad051 | ||
|
|
71b63ac6f9 | ||
|
|
659eb4ecfc | ||
|
|
2ff2c7db09 | ||
|
|
475e216ee0 | ||
|
|
226056fb46 | ||
|
|
580d18a41c | ||
|
|
0abd6c2a11 | ||
|
|
def43ecfc3 | ||
|
|
c4bfcf032a | ||
|
|
4a3a7d4812 | ||
|
|
dbe3e023ec | ||
|
|
c63110ae7d | ||
|
|
101f8beadb | ||
|
|
b16925b386 | ||
|
|
be5a146cca | ||
|
|
094fdcc51e | ||
|
|
307ad4a587 | ||
|
|
e867ba3c93 | ||
|
|
c3027c5901 | ||
|
|
30bf2d6e67 | ||
|
|
7fcab3bf31 | ||
|
|
eca8475e29 | ||
|
|
b340d46cdd | ||
|
|
ac721216ea | ||
|
|
e314d02212 | ||
|
|
6394e45bf2 | ||
|
|
ba5cc60c15 | ||
|
|
a0fed9956c | ||
|
|
58e9c97d76 | ||
|
|
ba154e4ec2 | ||
|
|
c4d7795280 | ||
|
|
25323d4ac1 | ||
|
|
15133cd0fc | ||
|
|
813554dc80 | ||
|
|
bd8980bc3c | ||
|
|
62a3713e1e | ||
|
|
1fb03de7be | ||
|
|
fa92a10eb7 | ||
|
|
bf20f247eb | ||
|
|
560b51e2f6 |
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 := ®istryStub{err: errors.New("no active binding")}
|
||||
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", AdvisoryText: "локальный ответ", FinishReason: cognitive.FinishCompleted}}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"}`))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user