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) {
|
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
|
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
|
var result compatibleChatResponse
|
||||||
status, err := a.requestJSON(ctx, http.MethodPost, "/chat/completions", payload, &result)
|
status, err := a.requestJSON(ctx, http.MethodPost, "/chat/completions", payload, &result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -151,6 +151,19 @@ func (a *OpenAICompatibleLocalAdapter) Advise(ctx context.Context, request cogni
|
|||||||
return response, nil
|
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) {
|
func (a *OpenAICompatibleLocalAdapter) requestJSON(ctx context.Context, method, path string, input, output any) (int, error) {
|
||||||
var body io.Reader
|
var body io.Reader
|
||||||
if input != nil {
|
if input != nil {
|
||||||
|
|||||||
@@ -92,3 +92,32 @@ func TestOpenAICompatibleLocalAdapterDisablesTemplateThinkingForBoundedAdvisory(
|
|||||||
t.Fatal("expected bounded advisory to disable template thinking")
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/identity"
|
||||||
"git.cin.su/m/Assistant/contracts/interaction"
|
"git.cin.su/m/Assistant/contracts/interaction"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
@@ -98,3 +99,22 @@ func (r *InteractionRepository) Utterances(ctx context.Context, id interaction.C
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"html"
|
"html"
|
||||||
@@ -19,9 +20,9 @@ import (
|
|||||||
var ErrSearchRejected = errors.New("web search request rejected")
|
var ErrSearchRejected = errors.New("web search request rejected")
|
||||||
|
|
||||||
var (
|
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>`)
|
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>`)
|
bingSnippetPattern = regexp.MustCompile(`(?is)<li[^>]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?<p[^>]*>(.*?)</p>`)
|
||||||
tagPattern = regexp.MustCompile(`(?s)<[^>]*>`)
|
tagPattern = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||||
termPattern = regexp.MustCompile(`(?i)[\p{L}\p{N}]{2,}`)
|
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
|
// cities, weather terms, entities or model names: the complete semantic query
|
||||||
// comes from the interpretation layer.
|
// comes from the interpretation layer.
|
||||||
type SearchConnector struct {
|
type SearchConnector struct {
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
EndpointTemplate string
|
EndpointTemplate string
|
||||||
AllowedHosts map[string]struct{}
|
AllowedHosts map[string]struct{}
|
||||||
MaxBytes int64
|
FallbackEndpoints []SearchEndpoint
|
||||||
MaxResults int
|
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) {
|
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") {
|
if request.Plan.Validate() != nil || strings.TrimSpace(request.Query) == "" || nowUnixMillis < 1 || !strings.Contains(c.EndpointTemplate, "%s") {
|
||||||
return nil, ErrSearchRejected
|
return nil, ErrSearchRejected
|
||||||
}
|
}
|
||||||
endpoint, err := url.Parse(strings.Replace(c.EndpointTemplate, "%s", url.QueryEscape(strings.TrimSpace(request.Query)), 1))
|
endpoints := make([]SearchEndpoint, 0, 1+len(c.FallbackEndpoints))
|
||||||
if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" || !c.allowedHost(strings.ToLower(endpoint.Hostname())) {
|
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
|
return nil, ErrSearchRejected
|
||||||
}
|
}
|
||||||
client := c.Client
|
client := c.Client
|
||||||
@@ -63,6 +98,10 @@ func (c SearchConnector) Collect(ctx context.Context, request epistemics.WebSear
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrSearchRejected
|
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)
|
response, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -91,7 +130,8 @@ func (c SearchConnector) Collect(ctx context.Context, request epistemics.WebSear
|
|||||||
snapshotID := epistemics.SourceSnapshotID("snapshot:search:" + base)
|
snapshotID := epistemics.SourceSnapshotID("snapshot:search:" + base)
|
||||||
results := make([]epistemics.ResearchCandidate, 0, len(links))
|
results := make([]epistemics.ResearchCandidate, 0, len(links))
|
||||||
for index, link := range links {
|
for index, link := range links {
|
||||||
title := cleanHTML(string(link[1]))
|
linkURL := resolveSearchResultLocator(extractHref(string(link[1])), endpoint)
|
||||||
|
title := cleanHTML(string(link[2]))
|
||||||
snippet := ""
|
snippet := ""
|
||||||
if index < len(snippets) {
|
if index < len(snippets) {
|
||||||
snippet = cleanHTML(string(snippets[index][1]))
|
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{
|
results = append(results, epistemics.ResearchCandidate{
|
||||||
PropositionKey: request.PropositionKey + ":result:" + ordinal,
|
PropositionKey: request.PropositionKey + ":result:" + ordinal,
|
||||||
Statement: statement,
|
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},
|
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))},
|
Excerpt: epistemics.SourceExcerpt{ID: excerptID, SnapshotID: snapshotID, Text: statement, Start: 0, End: int64(len(statement))},
|
||||||
Evidence: epistemics.EvidenceObserved, Coverage: epistemics.CoverageComplete, Conflict: epistemics.ConflictNone,
|
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 {
|
func searchStatementRelatesToQuery(query, statement string) bool {
|
||||||
terms := termPattern.FindAllString(strings.ToLower(query), -1)
|
terms := termPattern.FindAllString(strings.ToLower(query), -1)
|
||||||
content := strings.ToLower(statement)
|
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
|
usable := 0
|
||||||
for _, term := range terms {
|
for _, term := range terms {
|
||||||
if _, stop := searchStopTerms[term]; stop || len([]rune(term)) < 4 {
|
if _, stop := searchStopTerms[term]; stop || len([]rune(term)) < 4 {
|
||||||
@@ -138,7 +183,38 @@ func searchStatementRelatesToQuery(query, statement string) bool {
|
|||||||
return usable == 0
|
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 {
|
func (c SearchConnector) limit() int {
|
||||||
if c.MaxResults <= 0 || c.MaxResults > 8 {
|
if c.MaxResults <= 0 || c.MaxResults > 8 {
|
||||||
return 3
|
return 3
|
||||||
@@ -148,3 +224,53 @@ func (c SearchConnector) limit() int {
|
|||||||
func cleanHTML(value string) string {
|
func cleanHTML(value string) string {
|
||||||
return strings.Join(strings.Fields(html.UnescapeString(tagPattern.ReplaceAllString(value, ""))), " ")
|
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
|
## 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 new canonical owner.
|
||||||
- No generic scheduler/event-bus/autonomous-agent loop.
|
- No generic scheduler/event-bus/autonomous-agent loop.
|
||||||
- No new third-party dependency.
|
- No new third-party dependency.
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Examples include: a reminder after notification, waiting work requiring a decisi
|
|||||||
|
|
||||||
## Non-goals
|
## 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 universal object ontology.
|
||||||
- No reminder-specific parallel subsystem.
|
- 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"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.cin.su/m/Assistant/contracts/capability"
|
"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 {
|
func fleetReadinessHandler(client *http.Client, preferred capability.FleetTier, endpoints []fleetEndpoint) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
members := make([]capability.FleetMember, 0, len(endpoints))
|
members := make([]capability.FleetMember, 0, len(endpoints))
|
||||||
for _, endpoint := range endpoints {
|
members = make([]capability.FleetMember, len(endpoints))
|
||||||
member := capability.FleetMember{ProviderInstanceID: capability.ProviderInstanceID(endpoint.ProviderID), ModelDescriptorID: capability.ModelDescriptorID(endpoint.ModelID), Tier: endpoint.Tier, Reason: "NOT_CONFIGURED"}
|
var waitGroup sync.WaitGroup
|
||||||
if strings.TrimSpace(endpoint.URL) == "" {
|
for index, endpoint := range endpoints {
|
||||||
members = append(members, member)
|
waitGroup.Add(1)
|
||||||
continue
|
go func(index int, endpoint fleetEndpoint) {
|
||||||
}
|
defer waitGroup.Done()
|
||||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, endpoint.URL, nil)
|
members[index] = probeFleetEndpoint(r.Context(), client, endpoint)
|
||||||
if err == nil {
|
}(index, endpoint)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
waitGroup.Wait()
|
||||||
readiness, err := fleetdomain.BuildFleetReadiness(time.Now().UnixMilli(), members)
|
readiness, err := fleetdomain.BuildFleetReadiness(time.Now().UnixMilli(), members)
|
||||||
response := fleetStatusResponse{Readiness: readiness, Preferred: preferred}
|
response := fleetStatusResponse{Readiness: readiness, Preferred: preferred}
|
||||||
if err == nil {
|
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 }
|
func fleetContext(ctx context.Context) context.Context { return ctx }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.cin.su/m/Assistant/contracts/capability"
|
"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) {
|
func TestFleetReadinessSendsConfiguredProviderHeaders(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get("Authorization") != "Bearer fleet-key" {
|
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 {
|
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 {
|
switch request.Meaning.InformationRequestKind {
|
||||||
case interaction.InformationRequestCognitiveModelInventory:
|
case interaction.InformationRequestCognitiveModelInventory:
|
||||||
text = "Запрос понят как проверка моделей. Встроенный режим Домового доступен; я могу показать подключённые модели по назначению, качеству, скорости и стоимости. Ключи и пароли в разговор не нужны."
|
text = "Сейчас не смог проверить подключённые модели."
|
||||||
case interaction.InformationRequestCapabilityInventory:
|
case interaction.InformationRequestCapabilityInventory:
|
||||||
text = "Запрос понят как проверка возможностей. Я могу объяснить доступные capabilities и отличить то, что уже подключено, от того, что пока является только планом или capability gap."
|
text = "Сейчас не смог проверить доступные возможности."
|
||||||
case interaction.InformationRequestCognitiveChannelStatus:
|
case interaction.InformationRequestCognitiveChannelStatus:
|
||||||
text = "Запрос понят как проверка когнитивного канала. Локальный режим остаётся доступен; я объясню наблюдаемое состояние и что можно сделать без ожидания внешней модели."
|
text = "Локальный канал Домового доступен; подробности состояния дам по запросу."
|
||||||
case interaction.InformationRequestCognitiveRecovery:
|
case interaction.InformationRequestCognitiveRecovery:
|
||||||
text = "Запрос понят как восстановление ИИ. Начинаю с локального маршрута: фиксирую, что недоступно, проверяю безопасные варианты восстановления и сформулирую следующий обратимый шаг."
|
text = "Встроенный канал сейчас недоступен."
|
||||||
default:
|
default:
|
||||||
switch request.Meaning.SpeechAct {
|
switch request.Meaning.SpeechAct {
|
||||||
case "REQUEST_TO_INVESTIGATE":
|
case "REQUEST_TO_INVESTIGATE":
|
||||||
text = "Я принял это как исследование. Даже без внешней модели могу сформулировать гипотезы, отделить известное от неизвестного и предложить следующий проверяемый шаг."
|
text = "Понял задачу, но пока не смог получить проверяемый результат."
|
||||||
case "QUESTION":
|
case "QUESTION":
|
||||||
text = "Я отвечаю во встроенном режиме Домового. Если для полного ответа не хватает внешней модели, объясню это прямо и продолжу с тем, что можно проверить локально."
|
if !isIdentityInquiry(identityText) {
|
||||||
|
text = "Пока не смог получить проверяемый ответ."
|
||||||
|
}
|
||||||
|
case "CONVERSATIONAL":
|
||||||
|
text = "Я — Домовой, ваш помощник."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: text, FinishReason: cognitive.FinishCompleted}
|
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 {
|
func (g cognitiveGateway) invokeBuiltinAdvisory(ctx context.Context, request cognitive.GatewayAdvisoryRequest, maxOutputBytes int) cognitive.GatewayAdvisoryResponse {
|
||||||
if g.builtinLocal != nil && g.builtinLocal.Provider != nil {
|
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"}
|
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) {
|
func TestBuiltInAdvisoryUsesTrustedInformationIntentInsteadOfMessageKeywords(t *testing.T) {
|
||||||
request := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", Message: "arbitrary text", Meaning: cognitive.TaskMeaning{InformationRequestKind: interaction.InformationRequestCognitiveRecovery}}
|
request := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", Message: "arbitrary text", Meaning: cognitive.TaskMeaning{InformationRequestKind: interaction.InformationRequestCognitiveRecovery}}
|
||||||
response := builtinAdvisory(request)
|
response := builtinAdvisory(request)
|
||||||
if !strings.Contains(response.AdvisoryText, "восстановление ИИ") {
|
if response.AdvisoryText != "Встроенный канал сейчас недоступен." {
|
||||||
t.Fatalf("unexpected recovery response: %q", 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) {
|
func TestNoActiveBindingInvokesBuiltInLocalProvider(t *testing.T) {
|
||||||
registry := ®istryStub{err: errors.New("no active binding")}
|
registry := ®istryStub{err: errors.New("no active binding")}
|
||||||
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", AdvisoryText: "локальный ответ", FinishReason: cognitive.FinishCompleted}}
|
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"
|
cpuEndpoint := strings.TrimRight(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), "/") + "/models"
|
||||||
cpuHealthProbe := health.OptionalHTTPProbeWithHeaders(cpuReadinessClient, cpuEndpoint, cpuFleetHeaders)
|
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 := ""
|
mediumEndpoint := ""
|
||||||
mediumHeaders := map[string]string{}
|
mediumHeaders := map[string]string{}
|
||||||
mediumHealthProbe := health.Probe{}
|
mediumHealthProbe := health.Probe{}
|
||||||
@@ -136,7 +138,7 @@ func main() {
|
|||||||
mediumHeaders["Authorization"] = "Bearer " + mediumAPIKey
|
mediumHeaders["Authorization"] = "Bearer " + mediumAPIKey
|
||||||
}
|
}
|
||||||
mediumHealthProbe = health.OptionalHTTPProbeWithHeaders(mediumReadinessClient, mediumEndpoint, mediumHeaders)
|
mediumHealthProbe = health.OptionalHTTPProbeWithHeaders(mediumReadinessClient, mediumEndpoint, mediumHeaders)
|
||||||
mediumHealthProbe.Timeout = 10 * time.Second
|
mediumHealthProbe.Timeout = 2 * time.Second
|
||||||
}
|
}
|
||||||
readinessProbes := []health.Probe{
|
readinessProbes := []health.Probe{
|
||||||
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), "/")+"/healthz"),
|
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), "/")+"/healthz"),
|
||||||
@@ -149,7 +151,7 @@ func main() {
|
|||||||
if gpuConfigured {
|
if gpuConfigured {
|
||||||
gpuHeaders := map[string]string{"Authorization": "Bearer " + gpuKey}
|
gpuHeaders := map[string]string{"Authorization": "Bearer " + gpuKey}
|
||||||
gpuProbe := health.OptionalHTTPProbeWithHeaders(readinessClient, strings.TrimRight(gpuURL, "/")+"/models", gpuHeaders)
|
gpuProbe := health.OptionalHTTPProbeWithHeaders(readinessClient, strings.TrimRight(gpuURL, "/")+"/models", gpuHeaders)
|
||||||
gpuProbe.Timeout = 10 * time.Second
|
gpuProbe.Timeout = 2 * time.Second
|
||||||
readinessProbes = append(readinessProbes, gpuProbe)
|
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"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.cin.su/m/Assistant/contracts/authority"
|
"git.cin.su/m/Assistant/contracts/authority"
|
||||||
"git.cin.su/m/Assistant/contracts/capability"
|
"git.cin.su/m/Assistant/contracts/capability"
|
||||||
@@ -22,6 +25,56 @@ type cognitiveInterpretationClient struct {
|
|||||||
client *http.Client
|
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) {
|
func newCognitiveInterpretationClient(rawURL string, client *http.Client) (cognitiveInterpretationClient, error) {
|
||||||
base, err := url.Parse(rawURL)
|
base, err := url.Parse(rawURL)
|
||||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
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 {
|
func shouldRequestBroaderInterpretation(result interactiondomain.SemanticResult) bool {
|
||||||
switch result.Interpretation.Applicability {
|
return shouldRequestSemanticInterpretation(result)
|
||||||
case interaction.SemanticNeedsBroaderInterpret, interaction.SemanticLowConfidence, interaction.SemanticMixedIntent:
|
}
|
||||||
return true
|
|
||||||
default:
|
// 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
|
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 {
|
func isSimpleLocalQuestion(result interactiondomain.SemanticResult) bool {
|
||||||
|
|||||||
@@ -1,12 +1,55 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||||
|
"git.cin.su/m/Assistant/contracts/identity"
|
||||||
"git.cin.su/m/Assistant/contracts/interaction"
|
"git.cin.su/m/Assistant/contracts/interaction"
|
||||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
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) {
|
func TestSimpleQuestionUsesStrictInterpretationForSemanticRouting(t *testing.T) {
|
||||||
result := interactiondomain.SemanticResult{
|
result := interactiondomain.SemanticResult{
|
||||||
Interpretation: interaction.UtteranceInterpretation{
|
Interpretation: interaction.UtteranceInterpretation{
|
||||||
@@ -34,3 +77,51 @@ func TestOperationalQuestionStillUsesStrictInterpretation(t *testing.T) {
|
|||||||
t.Fatal("operational question must retain strict interpretation routing")
|
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"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -114,7 +116,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
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 {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -507,14 +509,127 @@ func main() {
|
|||||||
http.Error(w, "project focus unavailable", http.StatusServiceUnavailable)
|
http.Error(w, "project focus unavailable", http.StatusServiceUnavailable)
|
||||||
return
|
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)
|
semantic, err := interactiondomain.InterpretUtterance(utterance, projects, previousFocus, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "interpret chat message", http.StatusServiceUnavailable)
|
http.Error(w, "interpret chat message", http.StatusServiceUnavailable)
|
||||||
return
|
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
|
cognitiveAttempted := false
|
||||||
cognitiveAvailable := true
|
cognitiveAvailable := true
|
||||||
if shouldRequestBroaderInterpretation(semantic) {
|
semanticInterpretationRequested := shouldRequestSemanticInterpretation(semantic)
|
||||||
|
semanticReflexAudit := shouldAuditDeterministicReflex(semantic, string(utterance.ID), now)
|
||||||
|
if identityReply != "" {
|
||||||
|
semanticReflexAudit = false
|
||||||
|
}
|
||||||
|
if semanticInterpretationRequested || semanticReflexAudit {
|
||||||
cognitiveAttempted = true
|
cognitiveAttempted = true
|
||||||
projectNames := make([]string, 0, len(semantic.Projects))
|
projectNames := make([]string, 0, len(semantic.Projects))
|
||||||
for _, ref := range semantic.Projects {
|
for _, ref := range semantic.Projects {
|
||||||
@@ -522,9 +637,11 @@ func main() {
|
|||||||
projectNames = append(projectNames, ref.ProjectDisplayName)
|
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 {
|
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 {
|
} else {
|
||||||
cognitiveAvailable = false
|
cognitiveAvailable = false
|
||||||
}
|
}
|
||||||
@@ -532,6 +649,14 @@ func main() {
|
|||||||
cognitiveAvailable = false
|
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 {
|
if err := interactionRepository.AppendUtterance(r.Context(), conversation, utterance, intent, relations); err != nil {
|
||||||
http.Error(w, "save chat message", http.StatusInternalServerError)
|
http.Error(w, "save chat message", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -543,7 +668,7 @@ func main() {
|
|||||||
responseResult, responseErr := responseClient.Respond(r.Context(), r.Header.Get("Authorization"), continuity.HumanResponseRequest{
|
responseResult, responseErr := responseClient.Respond(r.Context(), r.Header.Get("Authorization"), continuity.HumanResponseRequest{
|
||||||
SourceUtteranceID: string(utterance.ID),
|
SourceUtteranceID: string(utterance.ID),
|
||||||
SourceIntentID: string(intent.ID),
|
SourceIntentID: string(intent.ID),
|
||||||
Message: utterance.Content,
|
Message: effectiveTaskMessage,
|
||||||
RequestedAtUnixMillis: now.UnixMilli(),
|
RequestedAtUnixMillis: now.UnixMilli(),
|
||||||
})
|
})
|
||||||
if semantic.Interpretation.SpeechAct == interaction.SpeechActCorrection {
|
if semantic.Interpretation.SpeechAct == interaction.SpeechActCorrection {
|
||||||
@@ -562,7 +687,7 @@ func main() {
|
|||||||
activeFocusName = previousFocus.ProjectDisplayName
|
activeFocusName = previousFocus.ProjectDisplayName
|
||||||
activeFocusProvenance = previousFocus.Provenance
|
activeFocusProvenance = previousFocus.Provenance
|
||||||
}
|
}
|
||||||
var planningReply string
|
planningReply := deterministicReply
|
||||||
workPlanned := false
|
workPlanned := false
|
||||||
needsCognition := semantic.NeedsCognition
|
needsCognition := semantic.NeedsCognition
|
||||||
projectID := ""
|
projectID := ""
|
||||||
@@ -591,7 +716,7 @@ func main() {
|
|||||||
ProjectID: work.ProjectRef(projectID),
|
ProjectID: work.ProjectRef(projectID),
|
||||||
ProjectDisplayName: activeFocusName,
|
ProjectDisplayName: activeFocusName,
|
||||||
ProjectProvenance: work.ProjectProvenanceRef(activeFocusProvenance),
|
ProjectProvenance: work.ProjectProvenanceRef(activeFocusProvenance),
|
||||||
Message: utterance.Content,
|
Message: effectiveTaskMessage,
|
||||||
SpeechAct: work.SpeechActRef(semantic.Interpretation.SpeechAct),
|
SpeechAct: work.SpeechActRef(semantic.Interpretation.SpeechAct),
|
||||||
SecondarySpeechAct: work.SpeechActRef(semantic.Interpretation.SecondarySpeechAct),
|
SecondarySpeechAct: work.SpeechActRef(semantic.Interpretation.SecondarySpeechAct),
|
||||||
AssertionVerification: epistemics.VerificationState(semantic.Interpretation.AssertionVerification),
|
AssertionVerification: epistemics.VerificationState(semantic.Interpretation.AssertionVerification),
|
||||||
@@ -605,12 +730,18 @@ func main() {
|
|||||||
helpPath = work.HelpPath{}
|
helpPath = work.HelpPath{}
|
||||||
}
|
}
|
||||||
switch semantic.InformationRequestKind {
|
switch semantic.InformationRequestKind {
|
||||||
|
case interaction.InformationRequestMemoryInventory:
|
||||||
|
planningReply = humanMemoryInventory(conversationHistory, assistantName)
|
||||||
|
needsCognition = false
|
||||||
|
case interaction.InformationRequestTimeDifference:
|
||||||
|
planningReply = humanTimeDifferenceReply(effectiveTaskMessage)
|
||||||
|
needsCognition = false
|
||||||
case interaction.InformationRequestCapabilityInventory:
|
case interaction.InformationRequestCapabilityInventory:
|
||||||
kinds, err := capabilityRepository.AvailableCapabilityKinds(r.Context(), session.PrincipalID)
|
kinds, err := capabilityRepository.AvailableCapabilityKinds(r.Context(), session.PrincipalID)
|
||||||
if err == nil && len(kinds) > 0 {
|
if err == nil && len(kinds) > 0 {
|
||||||
planningReply = "Сейчас у меня подключены реальные capabilities: " + humanCapabilityList(kinds) + ". Для рассуждений я использую отдельный Cognitive Gateway/Router, если локальный CANARY доступен. Возможности вроде отправки почты пока будут фиксироваться как capability gap, а не как выполненное действие."
|
planningReply = "Я умею общаться, искать проверяемую информацию, работать с подключёнными моделями и " + humanCapabilityList(kinds) + "."
|
||||||
} else {
|
} else {
|
||||||
planningReply = "Я умею записывать диалог и намерения, работать с текущим контекстом, строить планы и обращаться к Cognitive Gateway при доступности локального CANARY. Реальные action-capabilities подключаются через registry; если capability нет, я фиксирую gap."
|
planningReply = "Я умею общаться, сохранять контекст, искать проверяемую информацию и работать с подключёнными моделями. Дополнительные действия подключаются отдельно."
|
||||||
}
|
}
|
||||||
needsCognition = false
|
needsCognition = false
|
||||||
case interaction.InformationRequestCognitiveModelInventory:
|
case interaction.InformationRequestCognitiveModelInventory:
|
||||||
@@ -623,13 +754,36 @@ func main() {
|
|||||||
needsCognition = false
|
needsCognition = false
|
||||||
}
|
}
|
||||||
researchBacked := 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 {
|
if planID, idErr := ids.New(); idErr == nil {
|
||||||
result, researchErr := researchClient.SearchWeb(r.Context(), epistemics.WebSearchRequest{
|
parts := interactiondomain.IndependentRequestParts(effectiveTaskMessage)
|
||||||
Plan: epistemics.ResearchPlan{ID: epistemics.ResearchPlanID("plan:" + planID), Question: utterance.Content, SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"},
|
replies := make([]string, 0, len(parts))
|
||||||
PropositionKey: "question:" + string(utterance.ID), Query: utterance.Content,
|
for index, part := range parts {
|
||||||
})
|
if clarification := interactiondomain.LocalSearchClarification(part); clarification != "" {
|
||||||
planningReply, researchBacked = renderResearchReply(result, researchErr)
|
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
|
needsCognition = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -637,10 +791,18 @@ func main() {
|
|||||||
planningReply = helpPath.Reply
|
planningReply = helpPath.Reply
|
||||||
needsCognition = false
|
needsCognition = false
|
||||||
}
|
}
|
||||||
|
if travelReply := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory); travelReply != "" {
|
||||||
|
planningReply = travelReply
|
||||||
|
needsCognition = false
|
||||||
|
}
|
||||||
if planningReply == "" && needsCognition && cognitiveAttempted && !cognitiveAvailable {
|
if planningReply == "" && needsCognition && cognitiveAttempted && !cognitiveAvailable {
|
||||||
planningReply = humanCognitiveDegradedReply(utterance.Content)
|
planningReply = humanCognitiveDegradedReply(utterance.Content)
|
||||||
needsCognition = false
|
needsCognition = false
|
||||||
}
|
}
|
||||||
|
if creativeFallbackReply != "" && cognitiveAttempted && !cognitiveAvailable {
|
||||||
|
planningReply = creativeFallbackReply
|
||||||
|
needsCognition = false
|
||||||
|
}
|
||||||
var capabilityStatus capability.CapabilityResolutionStatus
|
var capabilityStatus capability.CapabilityResolutionStatus
|
||||||
var capabilityKind capability.CapabilityKind
|
var capabilityKind capability.CapabilityKind
|
||||||
var capabilityGapID capability.CapabilityGapID
|
var capabilityGapID capability.CapabilityGapID
|
||||||
@@ -717,6 +879,56 @@ func main() {
|
|||||||
planningReply = planningReply + " Для coding delegation подходящий agent implementation сейчас недоступен, поэтому я сохранил план без ложного запуска."
|
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{
|
response := interaction.ChatMessageResponse{
|
||||||
ConversationID: conversation.ID,
|
ConversationID: conversation.ID,
|
||||||
UtteranceID: utterance.ID,
|
UtteranceID: utterance.ID,
|
||||||
@@ -1449,21 +1661,121 @@ func hasCapabilityHint(hints []string, wanted string) bool {
|
|||||||
return false
|
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 {
|
if researchErr != nil {
|
||||||
return "Я не смог получить проверяемые данные из research-источника, поэтому не буду придумывать ответ.", false
|
return "Сейчас не удалось получить данные из подключённого источника. Попробую снова через доступный маршрут.", false
|
||||||
}
|
}
|
||||||
lines := make([]string, 0, len(result.Findings))
|
lines := make([]string, 0, 2)
|
||||||
for _, finding := range result.Findings {
|
for index, finding := range result.Findings {
|
||||||
if finding.State != epistemics.FindingSupported || strings.TrimSpace(finding.Excerpt.Text) == "" || strings.TrimSpace(finding.Source.Locator) == "" {
|
if finding.State != epistemics.FindingSupported || strings.TrimSpace(finding.Excerpt.Text) == "" || strings.TrimSpace(finding.Source.Locator) == "" {
|
||||||
continue
|
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 {
|
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 {
|
func humanCognitiveModelInventory(candidates []capability.ResolvedCognitiveBinding, inventoryErr error) string {
|
||||||
@@ -1473,28 +1785,82 @@ func humanCognitiveModelInventory(candidates []capability.ResolvedCognitiveBindi
|
|||||||
if len(candidates) == 0 {
|
if len(candidates) == 0 {
|
||||||
return "В реестре пока нет подтверждённых подключённых моделей. Встроенный локальный канал Домового доступен всегда; внешние модели добавляются через настройки подключения. Ключи и пароли в чат не выводятся."
|
return "В реестре пока нет подтверждённых подключённых моделей. Встроенный локальный канал Домового доступен всегда; внешние модели добавляются через настройки подключения. Ключи и пароли в чат не выводятся."
|
||||||
}
|
}
|
||||||
lines := make([]string, 0, len(candidates)+1)
|
type inventoryEntry struct {
|
||||||
lines = append(lines, "Подключённые модели из реестра:")
|
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 {
|
for _, candidate := range candidates {
|
||||||
modelName := string(candidate.Model.ProviderModelID)
|
modelName := string(candidate.Model.ProviderModelID)
|
||||||
if modelName == "" {
|
if modelName == "" {
|
||||||
modelName = "имя модели не подтверждено"
|
continue
|
||||||
}
|
}
|
||||||
status := cognitiveInventoryStatus(candidate)
|
status := conciseCognitiveInventoryStatus(candidate)
|
||||||
pricing := cognitiveInventoryPricing(candidate.Provider)
|
rank := cognitiveInventoryRank(candidate)
|
||||||
details := []string{status, "тип " + string(candidate.Provider.Kind), "политика " + string(candidate.Binding.DataPolicy)}
|
if index, exists := byName[modelName]; exists {
|
||||||
if pricing != "" {
|
if rank < entries[index].rank {
|
||||||
details = append(details, pricing)
|
entries[index].status = status
|
||||||
|
entries[index].rank = rank
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if candidate.Provider.AuthMode != "" && candidate.Provider.AuthMode != capability.AuthNone {
|
byName[modelName] = len(entries)
|
||||||
details = append(details, "доступ "+humanAuthMode(candidate.Provider.AuthMode))
|
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")
|
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 {
|
func cognitiveInventoryStatus(candidate capability.ResolvedCognitiveBinding) string {
|
||||||
if candidate.Provider.Health == capability.HealthAvailable && candidate.Model.Health == capability.HealthAvailable {
|
if candidate.Provider.Health == capability.HealthAvailable && candidate.Model.Health == capability.HealthAvailable {
|
||||||
if candidate.Binding.Activation == capability.ActivationActive {
|
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 {
|
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 healthErr != nil {
|
||||||
if recoveryRequested {
|
return "Не удалось проверить канал. Домовой остаётся на связи."
|
||||||
return "Я не вижу подтверждённого состояния локального канала. Домовой всё равно рядом: опишите, что изменилось, и я помогу подготовить аварийную настройку."
|
|
||||||
}
|
|
||||||
return "Я не вижу подтверждённого состояния локального канала, но Домовой работает. Можно продолжать разговор и проверить канал ещё раз."
|
|
||||||
}
|
}
|
||||||
var observation string
|
|
||||||
switch health.State {
|
switch health.State {
|
||||||
case capability.HealthAvailable:
|
case capability.HealthAvailable:
|
||||||
observation = "Основной локальный канал сейчас доступен."
|
return "Канал мышления доступен."
|
||||||
case capability.HealthDegraded:
|
case capability.HealthDegraded:
|
||||||
observation = "Основной локальный канал сейчас работает с ограничениями."
|
return "Канал мышления работает с ограничениями."
|
||||||
case capability.HealthUnavailable:
|
case capability.HealthUnavailable:
|
||||||
observation = "Основной локальный канал сейчас недоступен."
|
return "Канал мышления недоступен. Домовой остаётся на связи."
|
||||||
default:
|
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 {
|
func humanCognitiveDegradedReply(message string) string {
|
||||||
if strings.TrimSpace(message) == "" {
|
if strings.TrimSpace(message) == "" {
|
||||||
return "Основной канал мышления сейчас недоступен. Я рядом и продолжаю работать в локальном аварийном режиме."
|
return "Сейчас когнитивный канал недоступен; Домовой остаётся на связи."
|
||||||
}
|
}
|
||||||
return "Основной канал мышления сейчас недоступен. Домовой жив: сообщение принято, а я могу помочь разобраться с локальным каналом и настройкой восстановления."
|
return "Сейчас когнитивный канал недоступен. Повторю запрос через доступный маршрут, когда он восстановится."
|
||||||
}
|
}
|
||||||
|
|
||||||
func humanCapabilityList(kinds []capability.CapabilityKind) string {
|
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) {
|
func TestCognitiveChannelQuestionUsesHumanSafeHealthReply(t *testing.T) {
|
||||||
reply := humanCognitiveChannelReply(interaction.InformationRequestCognitiveChannelStatus, interaction.SpeechActQuestion, capability.Health{State: capability.HealthAvailable, CapabilityID: "provider-secret-model"}, nil)
|
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") {
|
if !strings.Contains(reply, "доступен") || strings.Contains(reply, "provider-secret-model") {
|
||||||
@@ -36,14 +66,14 @@ func TestCognitiveChannelQuestionUsesHumanSafeHealthReply(t *testing.T) {
|
|||||||
|
|
||||||
func TestCognitiveConfigurationRequestDoesNotPretendToMutate(t *testing.T) {
|
func TestCognitiveConfigurationRequestDoesNotPretendToMutate(t *testing.T) {
|
||||||
reply := humanCognitiveChannelReply(interaction.InformationRequestCognitiveRecovery, interaction.SpeechActDirective, capability.Health{State: capability.HealthUnavailable}, nil)
|
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)
|
t.Fatalf("unexpected configuration reply: %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCognitiveDegradedReplyKeepsDomovoyAlive(t *testing.T) {
|
func TestCognitiveDegradedReplyKeepsDomovoyAlive(t *testing.T) {
|
||||||
reply := humanCognitiveDegradedReply("Помоги настроить локальную модель")
|
reply := humanCognitiveDegradedReply("Помоги настроить локальную модель")
|
||||||
if !strings.Contains(reply, "Домовой жив") || !strings.Contains(reply, "принято") {
|
if !strings.Contains(reply, "когнитивный канал") || !strings.Contains(reply, "Повторю") {
|
||||||
t.Fatalf("unexpected degraded reply: %q", 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},
|
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},
|
Model: capability.ModelDescriptor{ProviderModelID: "example-model", Health: capability.HealthAvailable},
|
||||||
}}, nil)
|
}}, 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)
|
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) {
|
func TestCreatorSessionHandlerAcceptsConfiguredCredentials(t *testing.T) {
|
||||||
handler := creatorSessionHandler("recovery", "m", "password", []byte("01234567890123456789012345678901"), func() time.Time { return time.Unix(1000, 0) })
|
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"}`))
|
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"})
|
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/creator/chat/messages":
|
case r.Method == http.MethodPost && r.URL.Path == "/v1/creator/chat/messages":
|
||||||
w.WriteHeader(http.StatusCreated)
|
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:
|
default:
|
||||||
t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path)
|
t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
defer core.Close()
|
defer core.Close()
|
||||||
cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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()
|
defer cognitiveRuntime.Close()
|
||||||
handler, err := newHandler(core.URL, "http://continuity", "http://commit", core.Client(), cognitiveRuntime.URL)
|
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 {
|
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if response.Cognition != "verification_required" || strings.Contains(response.ReplyText, "небезопасная") || !strings.Contains(response.ReplyText, "не буду придумывать") {
|
if response.Cognition != string(cognitive.FinishCompleted) || strings.Contains(response.ReplyText, "небезопасная") || !strings.Contains(response.ReplyText, "продолжаю искать") {
|
||||||
t.Fatalf("unexpected truth-first response: %#v", response)
|
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("CORE_RUNTIME_URL", "http://core-runtime:8080"),
|
||||||
envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"),
|
envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"),
|
||||||
envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"),
|
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("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"),
|
||||||
envOr("AGENT_WORKER_URL", "http://agent-worker:8090"),
|
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
|
saved.NeedsCognition = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if saved.SpeechAct == interaction.SpeechActQuestion && saved.InformationRequestKind == "" && !saved.ResearchBacked && !saved.WorkPlanned && saved.Clarification != interaction.ClarificationRequired && !asksAttention(browserRequest.Message) {
|
if saved.NeedsCognition && !saved.WorkPlanned && saved.Clarification != interaction.ClarificationRequired {
|
||||||
reply = truthfulVerificationRequiredReply()
|
|
||||||
cognition = "verification_required"
|
|
||||||
saved.NeedsCognition = false
|
|
||||||
} else if saved.NeedsCognition && !saved.WorkPlanned && saved.Clarification != interaction.ClarificationRequired {
|
|
||||||
cognition = "unavailable"
|
cognition = "unavailable"
|
||||||
requestID, idErr := ids.New()
|
requestID, idErr := ids.New()
|
||||||
if idErr == nil {
|
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 {
|
func localChatContext(saved interaction.ChatMessageResponse) []cognitive.ContextExcerpt {
|
||||||
if strings.TrimSpace(saved.ActiveProjectName) == "" {
|
candidates := []cognitive.ContextCandidate{{
|
||||||
return []cognitive.ContextExcerpt{}
|
Excerpt: cognitive.ContextExcerpt{
|
||||||
}
|
ID: "assistant-identity",
|
||||||
bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{
|
SourceKind: "assistant-identity-policy",
|
||||||
DataPolicy: capability.DataPolicyLocalOnly,
|
SourceID: "domovoy",
|
||||||
MaxBytes: 2048,
|
Sensitivity: cognitive.SensitivityPublic,
|
||||||
Candidates: []cognitive.ContextCandidate{{
|
Text: "Ты — Домовой, локальный помощник пользователя. Отвечай от имени Домового. Не называй себя Qwen, Alibaba Cloud, поставщиком модели или внутренним runtime. Если спрашивают имя, скажи: «Меня зовут Домовой». Твоя задача — понять смысл запроса, найти путь к цели и довести помощь до полезного конечного результата; не ограничивайся пересказом отказа модели.",
|
||||||
|
},
|
||||||
|
Relevance: 100,
|
||||||
|
}}
|
||||||
|
if strings.TrimSpace(saved.ActiveProjectName) != "" {
|
||||||
|
candidates = append(candidates, cognitive.ContextCandidate{
|
||||||
Excerpt: cognitive.ContextExcerpt{
|
Excerpt: cognitive.ContextExcerpt{
|
||||||
ID: cognitive.ContextExcerptID("active-project-focus"),
|
ID: cognitive.ContextExcerptID("active-project-focus"),
|
||||||
SourceKind: "situation-active-project",
|
SourceKind: "situation-active-project",
|
||||||
@@ -533,8 +530,13 @@ func localChatContext(saved interaction.ChatMessageResponse) []cognitive.Context
|
|||||||
Sensitivity: cognitive.SensitivityPrivateAllowed,
|
Sensitivity: cognitive.SensitivityPrivateAllowed,
|
||||||
Text: "Текущий проект: " + strings.TrimSpace(saved.ActiveProjectName),
|
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 {
|
if err != nil {
|
||||||
return []cognitive.ContextExcerpt{}
|
return []cognitive.ContextExcerpt{}
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ import (
|
|||||||
"git.cin.su/m/Assistant/contracts/interaction"
|
"git.cin.su/m/Assistant/contracts/interaction"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLocalChatContextUsesOnlyActiveProject(t *testing.T) {
|
func TestLocalChatContextIncludesDomovoyIdentityAndOptionalActiveProject(t *testing.T) {
|
||||||
context := localChatContext(interaction.ChatMessageResponse{ActiveProjectName: "Домовой"})
|
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)
|
t.Fatalf("unexpected local context: %#v", context)
|
||||||
}
|
}
|
||||||
if empty := localChatContext(interaction.ChatMessageResponse{}); len(empty) != 0 {
|
if empty := localChatContext(interaction.ChatMessageResponse{}); len(empty) != 1 || empty[0].SourceID != "domovoy" {
|
||||||
t.Fatalf("expected empty context without active project: %#v", empty)
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(result)
|
_ = 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) {
|
mux.HandleFunc("POST /v1/research/web/search", func(w http.ResponseWriter, r *http.Request) {
|
||||||
var request epistemics.WebSearchRequest
|
var request epistemics.WebSearchRequest
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&request); err != nil {
|
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"
|
InformationRequestCognitiveModelInventory InformationRequestKind = "COGNITIVE_MODEL_INVENTORY"
|
||||||
InformationRequestCognitiveChannelStatus InformationRequestKind = "COGNITIVE_CHANNEL_STATUS"
|
InformationRequestCognitiveChannelStatus InformationRequestKind = "COGNITIVE_CHANNEL_STATUS"
|
||||||
InformationRequestCognitiveRecovery InformationRequestKind = "COGNITIVE_RECOVERY"
|
InformationRequestCognitiveRecovery InformationRequestKind = "COGNITIVE_RECOVERY"
|
||||||
|
InformationRequestMemoryInventory InformationRequestKind = "MEMORY_INVENTORY"
|
||||||
|
InformationRequestTimeDifference InformationRequestKind = "TIME_DIFFERENCE"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Conversation struct {
|
type Conversation struct {
|
||||||
|
|||||||
@@ -102,8 +102,11 @@ services:
|
|||||||
LISTEN_ADDR: ":8083"
|
LISTEN_ADDR: ":8083"
|
||||||
CORE_RUNTIME_URL: http://core-runtime:8080
|
CORE_RUNTIME_URL: http://core-runtime:8080
|
||||||
LOCAL_CPU_PROVIDER_URL: http://docker.cin.su:18082/v1
|
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_LIFEBOAT_PROVIDER_URL: ${LOCAL_LIFEBOAT_PROVIDER_URL:-}
|
||||||
LOCAL_GPU_PROVIDER_URL: http://192.168.220.91:11434
|
LOCAL_GPU_PROVIDER_URL: http://192.168.220.91:11434
|
||||||
|
COGNITIVE_ALLOW_CANARY: "true"
|
||||||
COGNITIVE_OWNER_PRINCIPAL_ID: domovoy-creator-principal
|
COGNITIVE_OWNER_PRINCIPAL_ID: domovoy-creator-principal
|
||||||
ports: ["19083:8083"]
|
ports: ["19083:8083"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"task_file": "dev/tasks/DEV-000291-mobile-acceptance-evidence.json",
|
"task_file": "dev/tasks/DEV-000294-creative-chat-routing.json",
|
||||||
"task_id": "DEV-000291",
|
"task_id": "DEV-000294",
|
||||||
"task_revision": 1
|
"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 "Я записал это как утверждение человека. Это не считается проверенным фактом."
|
return "Я записал это как утверждение человека. Это не считается проверенным фактом."
|
||||||
case interaction.SpeechActCorrection:
|
case interaction.SpeechActCorrection:
|
||||||
return "Исправление записано отдельной репликой; история не переписана."
|
return "Исправление записано отдельной репликой; история не переписана."
|
||||||
|
case interaction.SpeechActPreference:
|
||||||
|
return "Хорошо, учту это дальше."
|
||||||
case interaction.SpeechActQuestion, interaction.SpeechActRequestToInvestigate:
|
case interaction.SpeechActQuestion, interaction.SpeechActRequestToInvestigate:
|
||||||
return "Когнитивный маршрут сейчас недоступен, но сообщение сохранено и может быть обработано позже."
|
return "Когнитивный маршрут сейчас недоступен, но сообщение сохранено и может быть обработано позже."
|
||||||
default:
|
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 {
|
func BuildDeterministicCandidate(message string, corrects interaction.HumanUtteranceID, millis int64) interaction.SemanticCandidate {
|
||||||
id, _ := ids.New()
|
id, _ := ids.New()
|
||||||
tokens := semanticTokens(message)
|
tokens := semanticTokens(message)
|
||||||
@@ -213,9 +263,9 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
|
|||||||
}
|
}
|
||||||
questionWords := map[string]struct{}{"кто": {}, "что": {}, "как": {}, "почему": {}, "зачем": {}, "где": {}, "когда": {}, "сколько": {}, "какой": {}, "какая": {}, "какие": {}, "чей": {}}
|
questionWords := map[string]struct{}{"кто": {}, "что": {}, "как": {}, "почему": {}, "зачем": {}, "где": {}, "когда": {}, "сколько": {}, "какой": {}, "какая": {}, "какие": {}, "чей": {}}
|
||||||
conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй"}
|
conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй"}
|
||||||
investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "подум", "понят", "посмотри"}
|
investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "уточн", "подум", "понят", "посмотри", "попроб", "повтор", "снов"}
|
||||||
directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "спрос", "посчитай", "рассчитай", "ответь"}
|
directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "спрос", "найд", "посчитай", "рассчитай", "ответь"}
|
||||||
explanatoryRequestStems := []string{"расска", "раска", "объяс", "опиш", "покаж", "подскаж"}
|
explanatoryRequestStems := []string{"расска", "раска", "объяс", "опиш", "покаж", "подскаж", "сообщ"}
|
||||||
preferenceStems := []string{"предпоч", "хочу", "лучше", "не используй"}
|
preferenceStems := []string{"предпоч", "хочу", "лучше", "не используй"}
|
||||||
hypothesisStems := []string{"думаю", "кажется", "гипотез", "похоже"}
|
hypothesisStems := []string{"думаю", "кажется", "гипотез", "похоже"}
|
||||||
decisionStems := []string{"решил", "решение", "выбираю"}
|
decisionStems := []string{"решил", "решение", "выбираю"}
|
||||||
@@ -247,6 +297,10 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
|
|||||||
arithmeticMarker = true
|
arithmeticMarker = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if preferredNameFromTokens(tokens) {
|
||||||
|
scores[interaction.SpeechActPreference] += 8
|
||||||
|
scores[interaction.SpeechActAssertion] = 0
|
||||||
|
}
|
||||||
if len(tokens) >= 2 && tokens[0] == "я" && containsStemInTokens(tokens[1:], "настро", "сдела", "исправ", "удал", "созда", "перезапуст", "поменя", "добави") {
|
if len(tokens) >= 2 && tokens[0] == "я" && containsStemInTokens(tokens[1:], "настро", "сдела", "исправ", "удал", "созда", "перезапуст", "поменя", "добави") {
|
||||||
scores[interaction.SpeechActAssertion] += 5
|
scores[interaction.SpeechActAssertion] += 5
|
||||||
}
|
}
|
||||||
@@ -300,6 +354,39 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
|
|||||||
return scores
|
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) {
|
func topAct(scores map[interaction.SpeechAct]int) (interaction.SpeechAct, int) {
|
||||||
bestAct, bestScore := interaction.SpeechActUnknown, -1
|
bestAct, bestScore := interaction.SpeechActUnknown, -1
|
||||||
for _, act := range []interaction.SpeechAct{
|
for _, act := range []interaction.SpeechAct{
|
||||||
@@ -408,6 +495,9 @@ func capabilityHintsFor(primary, secondary interaction.SpeechAct, tokens []strin
|
|||||||
if sourceDependentQuestion(primary, secondary, tokens) {
|
if sourceDependentQuestion(primary, secondary, tokens) {
|
||||||
add("WEB_RESEARCH")
|
add("WEB_RESEARCH")
|
||||||
}
|
}
|
||||||
|
if isCreativeRequestTokens(tokens) {
|
||||||
|
add("COGNITIVE_REASONING")
|
||||||
|
}
|
||||||
if containsToken(tokens, "напомни") || containsToken(tokens, "напомин") {
|
if containsToken(tokens, "напомни") || containsToken(tokens, "напомин") {
|
||||||
add("SCHEDULE_REMINDER")
|
add("SCHEDULE_REMINDER")
|
||||||
}
|
}
|
||||||
@@ -449,18 +539,70 @@ func capabilityHintsFor(primary, secondary interaction.SpeechAct, tokens []strin
|
|||||||
return values
|
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
|
// sourceDependentQuestion identifies semantic classes whose answer depends on
|
||||||
// a current or externally observed source. It intentionally uses no named
|
// a current or externally observed source. It intentionally uses no named
|
||||||
// place, person or one-off question branch; when the cue is absent the
|
// place, person or one-off question branch; when the cue is absent the
|
||||||
// truth-first fallback remains in force.
|
// truth-first fallback remains in force.
|
||||||
func sourceDependentQuestion(primary, secondary interaction.SpeechAct, tokens []string) bool {
|
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 {
|
if !inquiry {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
currentCue := containsStemInTokens(tokens, "сейчас", "сегодня", "текущ", "актуаль", "последн", "прямо")
|
currentCue := containsStemInTokens(tokens, "сейчас", "сегодня", "текущ", "актуаль", "последн", "прямо")
|
||||||
observedStateCue := containsStemInTokens(tokens, "погод", "температур", "осад", "ветер", "курс", "цен", "стоим", "новост", "распис", "событ", "статус", "доступен", "работает", "запущен", "срок")
|
observedStateCue := containsStemInTokens(tokens, "погод", "температур", "осад", "ветер", "курс", "цен", "стоим", "новост", "распис", "событ", "статус", "доступен", "работает", "запущен", "срок") || approximateToken(tokens, "погод", 2)
|
||||||
return currentCue || observedStateCue
|
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 {
|
func informationRequestKind(message string, candidate interaction.SemanticCandidate) interaction.InformationRequestKind {
|
||||||
@@ -468,10 +610,16 @@ func informationRequestKind(message string, candidate interaction.SemanticCandid
|
|||||||
if len(tokens) == 0 {
|
if len(tokens) == 0 {
|
||||||
return ""
|
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
|
inquiry := candidate.PrimarySpeechAct == interaction.SpeechActQuestion || candidate.PrimarySpeechAct == interaction.SpeechActRequestToInvestigate || candidate.SecondarySpeechAct == interaction.SpeechActQuestion || candidate.SecondarySpeechAct == interaction.SpeechActRequestToInvestigate
|
||||||
cognitiveSubject := containsStemInTokens(tokens, "модел", "ии", "ai", "локальн", "когнитив", "канал")
|
cognitiveSubject := containsStemInTokens(tokens, "модел", "ии", "ai", "локальн", "когнитив", "канал")
|
||||||
inventoryCue := containsStemInTokens(tokens, "список", "доступ", "подключ", "вариант") || (containsMeaningfulInterrogative(tokens) && containsStemInTokens(tokens, "настро"))
|
inventoryCue := containsStemInTokens(tokens, "список", "доступ", "подключ", "вариант") || (containsMeaningfulInterrogative(tokens) && containsStemInTokens(tokens, "настро"))
|
||||||
inventoryRequest := inquiry || containsStemInTokens(tokens, "напиш", "покаж", "перечисл")
|
inventoryRequest := inquiry || containsStemInTokens(tokens, "напиш", "покаж", "перечисл", "сообщ", "вывед", "предостав")
|
||||||
if cognitiveSubject && inventoryRequest && inventoryCue {
|
if cognitiveSubject && inventoryRequest && inventoryCue {
|
||||||
return interaction.InformationRequestCognitiveModelInventory
|
return interaction.InformationRequestCognitiveModelInventory
|
||||||
}
|
}
|
||||||
@@ -489,6 +637,48 @@ func informationRequestKind(message string, candidate interaction.SemanticCandid
|
|||||||
return ""
|
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 {
|
func boundedConfidence(primaryScore, secondaryScore int, scores map[interaction.SpeechAct]int) int64 {
|
||||||
total := 0
|
total := 0
|
||||||
for _, score := range scores {
|
for _, score := range scores {
|
||||||
@@ -668,3 +858,9 @@ func removeCapabilityHint(values []string, denied string) []string {
|
|||||||
}
|
}
|
||||||
return result
|
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
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -121,6 +122,8 @@ func TestInformationRequestsAreClassifiedOnceByInteraction(t *testing.T) {
|
|||||||
{"что ты умеешь?", interaction.InformationRequestCapabilityInventory},
|
{"что ты умеешь?", interaction.InformationRequestCapabilityInventory},
|
||||||
{"проверь состояние локального канала", interaction.InformationRequestCognitiveChannelStatus},
|
{"проверь состояние локального канала", interaction.InformationRequestCognitiveChannelStatus},
|
||||||
{"нет связи с ИИ, восстанови канал", interaction.InformationRequestCognitiveRecovery},
|
{"нет связи с ИИ, восстанови канал", interaction.InformationRequestCognitiveRecovery},
|
||||||
|
{"сообщии список моделей", interaction.InformationRequestCognitiveModelInventory},
|
||||||
|
{"сообщите список подключённых моделей", interaction.InformationRequestCognitiveModelInventory},
|
||||||
}
|
}
|
||||||
for _, test := range cases {
|
for _, test := range cases {
|
||||||
result, err := InterpretUtterance(interaction.HumanUtterance{Content: test.message}, nil, nil, time.Unix(10, 0))
|
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) {
|
func TestDeterministicCandidateKeepsFirstPersonCompletedWorkAsAssertion(t *testing.T) {
|
||||||
candidate := BuildDeterministicCandidate("Я всё уже настроил.", "", time.Unix(10, 0).UnixMilli())
|
candidate := BuildDeterministicCandidate("Я всё уже настроил.", "", time.Unix(10, 0).UnixMilli())
|
||||||
if candidate.PrimarySpeechAct != interaction.SpeechActAssertion {
|
if candidate.PrimarySpeechAct != interaction.SpeechActAssertion {
|
||||||
|
|||||||
@@ -136,6 +136,42 @@ func ApplyCognitiveCandidate(result SemanticResult, message string, candidate in
|
|||||||
return result
|
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 {
|
func explicitProjectFocus(message string, projects []situation.ProjectDescriptor) *interaction.ProjectReference {
|
||||||
lower := strings.ToLower(message)
|
lower := strings.ToLower(message)
|
||||||
if !(strings.Contains(lower, "работаем с") || strings.Contains(lower, "вернемся к") || strings.Contains(lower, "вернёмся к") || strings.HasPrefix(strings.TrimSpace(lower), "теперь ")) {
|
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 + "."
|
return "Фокус переключён: " + result.ActiveFocus.ProjectDisplayName + "."
|
||||||
}
|
}
|
||||||
switch result.Interpretation.SpeechAct {
|
switch result.Interpretation.SpeechAct {
|
||||||
|
case interaction.SpeechActPreference:
|
||||||
|
if name := PreferredAssistantName(message); name != "" {
|
||||||
|
return "Хорошо, зови меня " + name + "."
|
||||||
|
}
|
||||||
|
return "Хорошо, учту это дальше."
|
||||||
case interaction.SpeechActAssertion:
|
case interaction.SpeechActAssertion:
|
||||||
if result.Interpretation.Applicability == interaction.SemanticNeedsBroaderInterpret || result.Interpretation.Applicability == interaction.SemanticLowConfidence {
|
if result.Interpretation.Applicability == interaction.SemanticNeedsBroaderInterpret || result.Interpretation.Applicability == interaction.SemanticLowConfidence {
|
||||||
return "Смысл сообщения пока понят недостаточно надёжно. Я сохраню его и при доступности более широкого когнитивного разбора не буду притворяться, что это уже проверенный факт."
|
return "Смысл сообщения пока понят недостаточно надёжно. Я сохраню его и при доступности более широкого когнитивного разбора не буду притворяться, что это уже проверенный факт."
|
||||||
@@ -288,6 +329,19 @@ func semanticReply(message string, result SemanticResult, previousFocus *situati
|
|||||||
return "Исправление записано отдельно, и я вижу здесь ещё новую рабочую просьбу; история сохранена без переписывания."
|
return "Исправление записано отдельно, и я вижу здесь ещё новую рабочую просьбу; история сохранена без переписывания."
|
||||||
}
|
}
|
||||||
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:
|
case interaction.SpeechActQuestion:
|
||||||
lower := strings.ToLower(message)
|
lower := strings.ToLower(message)
|
||||||
if previousFocus != nil && (strings.Contains(lower, "сейчас") || strings.Contains(lower, "что мы") || strings.Contains(lower, "делаем")) {
|
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)
|
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 {
|
func isCurrentFocusQuestion(message string, act interaction.SpeechAct) bool {
|
||||||
if act != interaction.SpeechActQuestion {
|
if act != interaction.SpeechActQuestion {
|
||||||
return false
|
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) {
|
func TestInterpretUtteranceDirectiveRecordsOutcomeWithoutExecution(t *testing.T) {
|
||||||
result := interpretForTest(t, "Поменяй базу на УПО1.", activeFocus("upo", "УПО"), "")
|
result := interpretForTest(t, "Поменяй базу на УПО1.", activeFocus("upo", "УПО"), "")
|
||||||
if result.Interpretation.SpeechAct != interaction.SpeechActDirective {
|
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) {
|
func TestInterpretUtteranceModalExplanationRequestDefersToCognition(t *testing.T) {
|
||||||
for _, message := range []string{"можешь расказать об утре", "расскажи об утре", "про утро расскажешь"} {
|
for _, message := range []string{"можешь расказать об утре", "расскажи об утре", "про утро расскажешь"} {
|
||||||
result := interpretForTest(t, message, nil, "")
|
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)
|
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.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)
|
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.CapabilityKindInspectProject, "inspect project context", epistemics.VerificationNotApplicable, millis)
|
||||||
addRequirement(candidate, capability.CapabilityKindReadDatabase, "collect database performance observations", epistemics.VerificationUnknown, millis)
|
addRequirement(candidate, capability.CapabilityKindReadDatabase, "collect database performance observations", epistemics.VerificationUnknown, millis)
|
||||||
addRequirement(candidate, capability.CapabilityKindQueryLogs, "collect runtime/log 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 {
|
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) {
|
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) {
|
func TestHelpPathCapabilityGapDoesNotBecomeCodexTask(t *testing.T) {
|
||||||
path := DetermineHelpPath(planningInputForTest("Подключи интеграцию с неизвестным сервисом, которого сейчас нет.", "DIRECTIVE", epistemics.VerificationNotApplicable))
|
path := DetermineHelpPath(planningInputForTest("Подключи интеграцию с неизвестным сервисом, которого сейчас нет.", "DIRECTIVE", epistemics.VerificationNotApplicable))
|
||||||
if path.Kind != work.HelpPathCapabilityGap || !path.CapabilityGap {
|
if path.Kind != work.HelpPathCapabilityGap || !path.CapabilityGap {
|
||||||
|
|||||||
Reference in New Issue
Block a user