Merge branch 'fix/g07' into fix/integrated

# Conflicts:
#	internal/ipc/api.go
#	internal/ipc/client.go
#	internal/llm/client.go
This commit is contained in:
kami
2026-08-01 14:36:48 +04:00
39 changed files with 1913 additions and 215 deletions
+47 -14
View File
@@ -16,14 +16,25 @@ type FeedQuery struct {
Category string
}
// feedNouns — the words that make a question about the feeds themselves.
// feedNouns — the words that name the feeds themselves. One of these is enough,
// with an ask, to make the turn a feed question.
var feedNouns = []string{
"лента", "ленте", "ленты", "лентах", "лентам",
"новости", "новостей", "новостях", "новостям",
"новое", "нового", "новенького",
"feed", "feeds", "news", "headlines",
}
// vagueNouns — the newness words that are NOT about the feeds by themselves.
//
// "что нового?" is the most common opener in the language and it is a greeting,
// not a request for headlines. It used to match here, so the shipping daemon —
// which has no feeds block — answered "я пока не читаю ленты, они не настроены",
// a configuration status in reply to hello. With feeds on it answered "в лентах
// пока ничего нового", which is no better. A vague noun claims the turn only
// when the utterance narrows it: a named topic ("что нового по технологиям"), or
// a feed noun somewhere in it ("что нового в лентах").
var vagueNouns = []string{"новое", "нового", "новенького", "new"}
// newnessMarkers — the "что нового" half. "нового" alone is in feedNouns
// because it carries the question on its own ("что нового?"); a bare "лента"
// needs the ask, which is what askMarkers below is for.
@@ -33,13 +44,15 @@ var askMarkers = []string{
}
// ParseFeedQuery reports whether an utterance asks what is new in the feeds, and
// which topic if it names one after "по"/"о"/"про"/"about".
// which topic if it names one after "по"/"об"/"про"/"about".
//
// Both a feed noun and an ask are required. "у меня новая лента в инстаграме" is
// a statement and must not be read as a request to recite headlines.
// A feed noun and an ask are required. "у меня новая лента в инстаграме" is a
// statement and must not be read as a request to recite headlines. A vague
// newness word counts as the noun only when a topic is named — see vagueNouns
// for why the bare "что нового?" must fall through.
func ParseFeedQuery(text string) (FeedQuery, bool) {
toks := planTokens(text)
noun, ask := false, false
noun, vague, ask := false, false, false
for _, t := range toks {
for _, n := range feedNouns {
if t == n {
@@ -47,6 +60,12 @@ func ParseFeedQuery(text string) (FeedQuery, bool) {
break
}
}
for _, n := range vagueNouns {
if t == n {
vague = true
break
}
}
for _, a := range askMarkers {
if t == a {
ask = true
@@ -54,24 +73,34 @@ func ParseFeedQuery(text string) (FeedQuery, bool) {
}
}
}
if !noun || !ask {
if !ask {
return FeedQuery{}, false
}
return FeedQuery{Category: feedCategory(toks)}, true
cat := feedCategory(toks)
if !noun && !(vague && cat != "") {
return FeedQuery{}, false
}
return FeedQuery{Category: cat}, true
}
// categoryPreps — the prepositions a topic follows. Russian marks the topic with
// a preposition ("по технологиям", "про политику"), so the word after one is the
// category; there is no stemming here, and the match against the configured
// category is a prefix comparison for exactly that reason.
var categoryPreps = map[string]bool{"по": true, "о": true, "об": true, "про": true, "about": true, "on": true}
//
// "о" is not in the list. It is one rune and it turns up as filler, a typo and
// half of "о'кей", so any utterance carrying a stray "о" produced a category of
// whatever word came next and she answered "по этой теме в лентах пока ничего"
// to a question that named no theme. "об" and "про" carry the same meaning and
// cannot be mistaken for anything else.
var categoryPreps = map[string]bool{"по": true, "об": true, "про": true, "about": true, "on": true}
func feedCategory(toks []string) string {
for i, t := range toks {
if categoryPreps[t] && i+1 < len(toks) {
next := toks[i+1]
// "по новостям" names no topic, it repeats the noun.
for _, n := range feedNouns {
for _, n := range append(append([]string{}, feedNouns...), vagueNouns...) {
if next == n {
return ""
}
@@ -82,12 +111,16 @@ func feedCategory(toks []string) string {
return ""
}
// CategoryMatches reports whether a note's text plausibly belongs to the
// category he named. Russian inflects the topic ("технологиям" vs the configured
// CategoryMatches reports whether a feed note's own category tag is the one he
// named. Russian inflects the topic ("технологиям" vs the configured
// "технологии"), and there is no stemmer in this repo, so the comparison is on a
// common prefix — long enough that "полит" and "погод" stay apart, short enough
// to survive a case ending.
func CategoryMatches(text, category string) bool {
//
// tag is the note's stored category (rss.NoteCategory), NOT the whole note. It
// used to be the whole note, which meant "что нового про погоду" matched any
// tech headline whose link happened to contain "pogod".
func CategoryMatches(tag, category string) bool {
if category == "" {
return true
}
@@ -95,7 +128,7 @@ func CategoryMatches(text, category string) bool {
if stem == "" {
return false
}
return strings.Contains(strings.ToLower(text), stem)
return strings.Contains(strings.ToLower(tag), stem)
}
// categoryStem cuts a word down to the part inflection leaves alone. 5 runes is
+13 -3
View File
@@ -9,13 +9,19 @@ func TestParseFeedQuery(t *testing.T) {
category string
}{
{"что нового в лентах?", true, ""},
{"что нового?", true, ""},
{"что нового по технологиям", true, "технологиям"},
{"какие новости?", true, ""},
{"что нового по технологиям?", true, "технологиям"},
{"расскажи новости про политику", true, "политику"},
{"что нового по новостям", true, ""},
{"what's new in the feeds?", true, ""},
{"any news about kubernetes", true, "kubernetes"},
// "что нового?" is a greeting. Claiming it made the shipping daemon
// answer hello with "я пока не читаю ленты — они не настроены".
{"что нового?", false, ""},
{"ну что нового", false, ""},
// A stray "о" is not a topic marker.
{"что нового в лентах, о боже", true, ""},
// Statements, not requests.
{"у меня новая лента в инстаграме", false, ""},
{"новости меня утомили", false, ""},
@@ -36,12 +42,16 @@ func TestParseFeedQuery(t *testing.T) {
func TestCategoryMatches(t *testing.T) {
// The inflected form he says must match the form the config spells.
if !CategoryMatches("Новый релиз [технологии]", "технологиям") {
if !CategoryMatches("технологии", "технологиям") {
t.Error("inflected category did not match")
}
if CategoryMatches("Новый релиз [технологии]", "политику") {
if CategoryMatches("технологии", "политику") {
t.Error("unrelated category matched")
}
// The tag, not the note. The link in a tech headline is not a weather report.
if CategoryMatches("технологии", "погоду") {
t.Error("a tech note matched a weather question")
}
if !CategoryMatches("anything", "") {
t.Error("an empty category must match everything")
}