nexo-brain 7.30.4 → 7.30.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
3
|
+
"version": "7.30.5",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/README.md
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
[Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
|
|
20
20
|
|
|
21
|
-
Version `7.30.
|
|
21
|
+
Version `7.30.5` is the current packaged-runtime line. Patch release over v7.30.4 - the morning briefing is now Morning preparation, with automatic relevance, changes since yesterday, next actions, relevant public context, and chat-addressable preference settings for non-technical users.
|
|
22
|
+
|
|
23
|
+
Previously in `7.30.4`: patch release over v7.30.3 - local runtime update post-sync now gives bounded Memory Fabric repair enough time to finish, and headless automations now treat `nexo_stop` as a terminal close so followup/deep-sleep runners do not reopen no-op protocol loops.
|
|
22
24
|
|
|
23
25
|
Previously in `7.30.3`: patch release over v7.30.2 - release closeout now protects the freshly written runtime backup from technical pruning and validates protocol evidence against the canonical `runtime/data/nexo.db` layout.
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
3
|
+
"version": "7.30.5",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
|
|
6
6
|
"homepage": "https://nexo-brain.com",
|
|
@@ -14,20 +14,34 @@ SUPPORTED_AUTOMATIONS = {"morning-agent"}
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
MORNING_AGENT_SCHEMA: dict[str, Any] = {
|
|
17
|
-
"schema_version":
|
|
17
|
+
"schema_version": 2,
|
|
18
18
|
"automation": "morning-agent",
|
|
19
|
-
"title": "Morning
|
|
19
|
+
"title": "Morning preparation",
|
|
20
20
|
"groups": [
|
|
21
21
|
{
|
|
22
22
|
"id": "content",
|
|
23
|
-
"label": "
|
|
23
|
+
"label": "What NEXO watches",
|
|
24
24
|
"items": [
|
|
25
|
+
{
|
|
26
|
+
"id": "auto_relevance",
|
|
27
|
+
"type": "boolean",
|
|
28
|
+
"label": "Automatic relevance",
|
|
29
|
+
"default": True,
|
|
30
|
+
"help": "NEXO decides what matters by urgency, change, impact, confidence and whether there is a useful next action.",
|
|
31
|
+
},
|
|
25
32
|
{
|
|
26
33
|
"id": "priorities",
|
|
27
34
|
"type": "boolean",
|
|
28
35
|
"label": "Priorities",
|
|
29
36
|
"default": True,
|
|
30
|
-
"help": "The most important things
|
|
37
|
+
"help": "The most important things to look at first today.",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "changes_since_yesterday",
|
|
41
|
+
"type": "boolean",
|
|
42
|
+
"label": "What changed",
|
|
43
|
+
"default": True,
|
|
44
|
+
"help": "Recent changes, new information and moved work that may affect the day.",
|
|
31
45
|
},
|
|
32
46
|
{
|
|
33
47
|
"id": "agenda",
|
|
@@ -72,19 +86,25 @@ MORNING_AGENT_SCHEMA: dict[str, Any] = {
|
|
|
72
86
|
"help": "Things that may stop progress, need your decision or could become a problem.",
|
|
73
87
|
},
|
|
74
88
|
{
|
|
75
|
-
"id": "
|
|
89
|
+
"id": "next_actions",
|
|
76
90
|
"type": "boolean",
|
|
77
|
-
"label": "
|
|
78
|
-
"default":
|
|
79
|
-
"help": "
|
|
91
|
+
"label": "Next actions",
|
|
92
|
+
"default": True,
|
|
93
|
+
"help": "A practical closing list of what to do first, what can wait and what needs a decision.",
|
|
80
94
|
},
|
|
81
95
|
{
|
|
82
|
-
"id": "
|
|
96
|
+
"id": "internal_refs",
|
|
83
97
|
"type": "boolean",
|
|
84
|
-
"label": "
|
|
98
|
+
"label": "Internal references",
|
|
85
99
|
"default": False,
|
|
86
|
-
"help": "
|
|
100
|
+
"help": "Technical file names, IDs or internal references. Keep this off for a cleaner human summary.",
|
|
87
101
|
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"id": "external",
|
|
106
|
+
"label": "Day context",
|
|
107
|
+
"items": [
|
|
88
108
|
{
|
|
89
109
|
"id": "weather",
|
|
90
110
|
"type": "boolean",
|
|
@@ -92,6 +112,50 @@ MORNING_AGENT_SCHEMA: dict[str, Any] = {
|
|
|
92
112
|
"default": True,
|
|
93
113
|
"help": "Today's weather from the location saved in Desktop or your residence in the profile, included only when the forecast can be verified.",
|
|
94
114
|
},
|
|
115
|
+
{
|
|
116
|
+
"id": "news",
|
|
117
|
+
"type": "boolean",
|
|
118
|
+
"label": "Relevant public context",
|
|
119
|
+
"default": True,
|
|
120
|
+
"help": "Public headlines only when they are current, verifiable and useful for the operator's day, work, location or interests.",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"id": "news_interests",
|
|
124
|
+
"type": "multi_choice",
|
|
125
|
+
"label": "Public context interests",
|
|
126
|
+
"default": ["automatic"],
|
|
127
|
+
"options": [
|
|
128
|
+
"automatic",
|
|
129
|
+
"business",
|
|
130
|
+
"technology",
|
|
131
|
+
"finance",
|
|
132
|
+
"local",
|
|
133
|
+
"health",
|
|
134
|
+
"legal",
|
|
135
|
+
"education",
|
|
136
|
+
"real_estate",
|
|
137
|
+
"science",
|
|
138
|
+
"culture",
|
|
139
|
+
"sports",
|
|
140
|
+
],
|
|
141
|
+
"exclusive_options": ["automatic"],
|
|
142
|
+
"help": "Use Automatic so NEXO infers useful topics, or choose areas you want watched in the morning.",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"id": "excluded_topics",
|
|
146
|
+
"type": "multi_choice",
|
|
147
|
+
"label": "Topics to avoid",
|
|
148
|
+
"default": [],
|
|
149
|
+
"options": ["politics", "sports", "celebrity", "crime", "crypto", "market_noise"],
|
|
150
|
+
"help": "Topics NEXO should avoid unless they are directly relevant to your work or safety.",
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"id": "why_shown",
|
|
154
|
+
"type": "boolean",
|
|
155
|
+
"label": "Explain why",
|
|
156
|
+
"default": False,
|
|
157
|
+
"help": "Adds a short reason when NEXO includes external context, useful while tuning the briefing.",
|
|
158
|
+
},
|
|
95
159
|
],
|
|
96
160
|
},
|
|
97
161
|
{
|
|
@@ -176,6 +240,36 @@ def _iter_schema_items(schema: dict[str, Any]):
|
|
|
176
240
|
yield item
|
|
177
241
|
|
|
178
242
|
|
|
243
|
+
def _normalize_multi_choice(raw_value: Any, item: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
244
|
+
options = [str(v) for v in list(item.get("options") or [])]
|
|
245
|
+
allowed = set(options)
|
|
246
|
+
warnings: list[str] = []
|
|
247
|
+
if isinstance(raw_value, (list, tuple, set)):
|
|
248
|
+
raw_items = list(raw_value)
|
|
249
|
+
else:
|
|
250
|
+
text = str(raw_value or "").strip()
|
|
251
|
+
if not text:
|
|
252
|
+
raw_items = []
|
|
253
|
+
else:
|
|
254
|
+
raw_items = [part.strip() for part in text.replace(";", ",").split(",")]
|
|
255
|
+
selected: list[str] = []
|
|
256
|
+
for raw_item in raw_items:
|
|
257
|
+
clean = str(raw_item or "").strip()
|
|
258
|
+
if not clean:
|
|
259
|
+
continue
|
|
260
|
+
if clean not in allowed:
|
|
261
|
+
warnings.append(f"{item.get('id')}: invalid option {clean}")
|
|
262
|
+
continue
|
|
263
|
+
if clean not in selected:
|
|
264
|
+
selected.append(clean)
|
|
265
|
+
exclusive = [str(v) for v in list(item.get("exclusive_options") or [])]
|
|
266
|
+
for exclusive_value in exclusive:
|
|
267
|
+
if exclusive_value in selected and len(selected) > 1:
|
|
268
|
+
selected = [exclusive_value]
|
|
269
|
+
break
|
|
270
|
+
return selected, warnings
|
|
271
|
+
|
|
272
|
+
|
|
179
273
|
def default_automation_preferences(name: str) -> dict[str, Any]:
|
|
180
274
|
schema = get_automation_preference_schema(name)
|
|
181
275
|
values: dict[str, Any] = {}
|
|
@@ -218,6 +312,10 @@ def validate_automation_preferences(name: str, payload: dict[str, Any] | None) -
|
|
|
218
312
|
values[key] = clean
|
|
219
313
|
else:
|
|
220
314
|
warnings.append(f"{key}: invalid choice")
|
|
315
|
+
elif kind in {"multi_choice", "multi-select", "multiselect"}:
|
|
316
|
+
selected, item_warnings = _normalize_multi_choice(raw_value, item)
|
|
317
|
+
values[key] = selected
|
|
318
|
+
warnings.extend(item_warnings)
|
|
221
319
|
elif kind == "number":
|
|
222
320
|
try:
|
|
223
321
|
values[key] = int(raw_value)
|
|
@@ -349,11 +447,16 @@ def format_automation_preferences_prompt_block(name_or_path: str) -> str:
|
|
|
349
447
|
"\n== STRUCTURED CONTENT PREFERENCES FOR THIS AUTOMATION ==\n"
|
|
350
448
|
f"{compact}\n"
|
|
351
449
|
"Morning briefing intent: act like a professional personal assistant preparing the operator for the day. "
|
|
352
|
-
"
|
|
450
|
+
"The result is a start-of-day preparation, not a settings checklist or a report dump.\n"
|
|
451
|
+
"Do not merely list available records; filter, rank, and explain what deserves attention first. "
|
|
452
|
+
"Score relevance by urgency, change since yesterday, impact, actionability, confidence, and user preference.\n"
|
|
353
453
|
"Adapt the emphasis from the operator profile, role, recent activity, and context. "
|
|
354
454
|
"Do not ask the user to choose a user type manually and do not assume a profession unless the context supports it.\n"
|
|
455
|
+
"If automatic relevance is enabled, omit low-value items even when their source is enabled. "
|
|
456
|
+
"Prefer a short Top 3, important changes, commitments, risks, and practical next actions.\n"
|
|
355
457
|
"Use these preferences to decide what to include, omit, and emphasize. "
|
|
356
|
-
"Disabled/unavailable data sources must not be invented; news and weather require verified collected data
|
|
458
|
+
"Disabled/unavailable data sources must not be invented; news and weather require verified collected data. "
|
|
459
|
+
"Relevant public context should be included only when it helps the operator understand the day, their work, their location, or a declared interest.\n"
|
|
357
460
|
)
|
|
358
461
|
|
|
359
462
|
|
|
@@ -10,10 +10,18 @@ from typing import Any
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
AUTOMATION_ITEM_ES = {
|
|
13
|
+
"auto_relevance": (
|
|
14
|
+
"Relevancia automática",
|
|
15
|
+
"NEXO decide qué merece aparecer usando urgencia, cambios, impacto, confianza y si hay una acción útil.",
|
|
16
|
+
),
|
|
13
17
|
"priorities": (
|
|
14
18
|
"Prioridades",
|
|
15
19
|
"Incluye las cosas más importantes del día: lo urgente, lo atrasado y lo que conviene resolver primero.",
|
|
16
20
|
),
|
|
21
|
+
"changes_since_yesterday": (
|
|
22
|
+
"Cambios importantes",
|
|
23
|
+
"Incluye novedades recientes, cambios desde ayer y asuntos que se han movido mientras no estabas.",
|
|
24
|
+
),
|
|
17
25
|
"agenda": (
|
|
18
26
|
"Agenda",
|
|
19
27
|
"Añade citas, eventos o bloques de calendario que NEXO conozca para que sepas qué viene hoy.",
|
|
@@ -38,13 +46,29 @@ AUTOMATION_ITEM_ES = {
|
|
|
38
46
|
"Bloqueos y riesgos",
|
|
39
47
|
"Señala bloqueos, riesgos o asuntos que pueden frenarte si no los atiendes.",
|
|
40
48
|
),
|
|
49
|
+
"next_actions": (
|
|
50
|
+
"Siguientes acciones",
|
|
51
|
+
"Cierra el resumen con pasos prácticos: qué mirar primero, qué puede esperar y qué decisión falta.",
|
|
52
|
+
),
|
|
41
53
|
"internal_refs": (
|
|
42
54
|
"Referencias internas",
|
|
43
55
|
"Incluye referencias internas útiles. Déjalo desactivado si prefieres un resumen más limpio.",
|
|
44
56
|
),
|
|
45
57
|
"news": (
|
|
46
|
-
"
|
|
47
|
-
"Añade
|
|
58
|
+
"Actualidad relevante",
|
|
59
|
+
"Añade contexto público solo si es actual, verificable y útil para tu día, trabajo, ubicación o intereses.",
|
|
60
|
+
),
|
|
61
|
+
"news_interests": (
|
|
62
|
+
"Temas de actualidad",
|
|
63
|
+
"Temas que NEXO debe vigilar. Usa automático para que los infiera por tu contexto y hábitos.",
|
|
64
|
+
),
|
|
65
|
+
"excluded_topics": (
|
|
66
|
+
"Temas a evitar",
|
|
67
|
+
"Temas que NEXO debe evitar salvo que sean directamente relevantes para tu trabajo o seguridad.",
|
|
68
|
+
),
|
|
69
|
+
"why_shown": (
|
|
70
|
+
"Explicar por qué",
|
|
71
|
+
"Añade una frase breve explicando por qué una noticia o dato externo entra en el resumen.",
|
|
48
72
|
),
|
|
49
73
|
"weather": (
|
|
50
74
|
"Tiempo",
|
|
@@ -69,6 +93,48 @@ AUTOMATION_ITEM_ES = {
|
|
|
69
93
|
}
|
|
70
94
|
|
|
71
95
|
|
|
96
|
+
AUTOMATION_OPTION_ALIASES_ES = {
|
|
97
|
+
"news_interests": {
|
|
98
|
+
"automatic": ["automatico", "automático", "auto", "nexo decide", "por defecto"],
|
|
99
|
+
"business": ["empresa", "negocio", "negocios", "empresas", "economia", "economía"],
|
|
100
|
+
"technology": ["tecnologia", "tecnología", "ia", "inteligencia artificial", "software"],
|
|
101
|
+
"finance": ["finanzas", "financiero", "mercados", "bolsa"],
|
|
102
|
+
"local": ["local", "zona", "ciudad", "mallorca", "ubicacion", "ubicación"],
|
|
103
|
+
"health": ["salud", "sanidad", "medicina", "medico", "médico"],
|
|
104
|
+
"legal": ["legal", "leyes", "normativa", "justicia"],
|
|
105
|
+
"education": ["educacion", "educación", "formacion", "formación", "estudios"],
|
|
106
|
+
"real_estate": ["inmobiliario", "vivienda", "arquitectura", "urbanismo"],
|
|
107
|
+
"science": ["ciencia", "investigacion", "investigación", "innovacion", "innovación"],
|
|
108
|
+
"culture": ["cultura", "sociedad"],
|
|
109
|
+
"sports": ["deportes", "deporte"],
|
|
110
|
+
},
|
|
111
|
+
"excluded_topics": {
|
|
112
|
+
"politics": ["politica", "política", "partidos", "elecciones"],
|
|
113
|
+
"sports": ["deportes", "deporte", "futbol", "fútbol"],
|
|
114
|
+
"celebrity": ["famosos", "celebridades", "prensa rosa", "television", "televisión"],
|
|
115
|
+
"crime": ["sucesos", "crimen", "delitos"],
|
|
116
|
+
"crypto": ["cripto", "crypto", "bitcoin", "ethereum"],
|
|
117
|
+
"market_noise": ["ruido de mercado", "bolsa diaria", "mercados diarios"],
|
|
118
|
+
},
|
|
119
|
+
"length": {
|
|
120
|
+
"short": ["corto", "breve"],
|
|
121
|
+
"normal": ["normal", "medio"],
|
|
122
|
+
"detailed": ["detallado", "detalle", "mas detalle", "más detalle"],
|
|
123
|
+
},
|
|
124
|
+
"tone": {
|
|
125
|
+
"direct": ["directo"],
|
|
126
|
+
"warm": ["cercano", "calido", "cálido"],
|
|
127
|
+
"executive": ["ejecutivo", "directivo"],
|
|
128
|
+
"personal": ["personal"],
|
|
129
|
+
},
|
|
130
|
+
"format": {
|
|
131
|
+
"sections": ["secciones"],
|
|
132
|
+
"bullets": ["puntos", "lista", "bullets"],
|
|
133
|
+
"narrative": ["narrativo", "texto"],
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
72
138
|
def _fold(value: str) -> str:
|
|
73
139
|
return re.sub(r"\s+", " ", str(value or "").strip().lower())
|
|
74
140
|
|
|
@@ -124,11 +190,43 @@ def _coerce_value(value: Any, entry: dict[str, Any]) -> Any:
|
|
|
124
190
|
raw = str(value).strip().lower()
|
|
125
191
|
return raw in {"1", "true", "yes", "on", "si", "sí", "activar", "enabled"}
|
|
126
192
|
options = [str(option.get("value") if isinstance(option, dict) else option) for option in entry.get("options") or []]
|
|
193
|
+
aliases = entry.get("option_aliases") if isinstance(entry.get("option_aliases"), dict) else {}
|
|
194
|
+
alias_map: dict[str, str] = {}
|
|
195
|
+
for option in options:
|
|
196
|
+
alias_map[_fold(option)] = option
|
|
197
|
+
for alias in list((aliases.get(option) if isinstance(aliases.get(option), list) else []) or []):
|
|
198
|
+
alias_map[_fold(alias)] = option
|
|
199
|
+
if kind in {"multi_choice", "multi-select", "multiselect"}:
|
|
200
|
+
if isinstance(value, (list, tuple, set)):
|
|
201
|
+
raw_items = list(value)
|
|
202
|
+
else:
|
|
203
|
+
raw_items = [part.strip() for part in str(value or "").replace(";", ",").split(",")]
|
|
204
|
+
selected: list[str] = []
|
|
205
|
+
invalid: list[str] = []
|
|
206
|
+
for raw_item in raw_items:
|
|
207
|
+
text = str(raw_item or "").strip()
|
|
208
|
+
if not text:
|
|
209
|
+
continue
|
|
210
|
+
mapped = alias_map.get(_fold(text), text if text in options else "")
|
|
211
|
+
if not mapped:
|
|
212
|
+
invalid.append(text)
|
|
213
|
+
continue
|
|
214
|
+
if mapped not in selected:
|
|
215
|
+
selected.append(mapped)
|
|
216
|
+
if invalid:
|
|
217
|
+
raise ValueError(f"Invalid value '{invalid[0]}'. Valid values: {', '.join(options)}")
|
|
218
|
+
exclusive = [str(v) for v in list(entry.get("exclusive_options") or [])]
|
|
219
|
+
for exclusive_value in exclusive:
|
|
220
|
+
if exclusive_value in selected and len(selected) > 1:
|
|
221
|
+
selected = [exclusive_value]
|
|
222
|
+
break
|
|
223
|
+
return selected
|
|
127
224
|
if options:
|
|
128
225
|
raw = str(value).strip()
|
|
129
|
-
if raw
|
|
226
|
+
mapped = alias_map.get(_fold(raw), raw if raw in options else "")
|
|
227
|
+
if mapped not in options:
|
|
130
228
|
raise ValueError(f"Invalid value '{raw}'. Valid values: {', '.join(options)}")
|
|
131
|
-
return
|
|
229
|
+
return mapped
|
|
132
230
|
return value
|
|
133
231
|
|
|
134
232
|
|
|
@@ -192,6 +290,7 @@ def _automation_entries(*, include_values: bool, locale: str) -> list[dict[str,
|
|
|
192
290
|
"aliases": [
|
|
193
291
|
f"morning-agent.{item_id}",
|
|
194
292
|
f"resumen.{item_id}",
|
|
293
|
+
f"morning.{item_id}",
|
|
195
294
|
str(item.get("label") or ""),
|
|
196
295
|
item_id.replace("_", " "),
|
|
197
296
|
label,
|
|
@@ -202,6 +301,8 @@ def _automation_entries(*, include_values: bool, locale: str) -> list[dict[str,
|
|
|
202
301
|
"help": help_text,
|
|
203
302
|
"type": str(item.get("type") or "text"),
|
|
204
303
|
"options": list(item.get("options") or []),
|
|
304
|
+
"exclusive_options": list(item.get("exclusive_options") or []),
|
|
305
|
+
"option_aliases": AUTOMATION_OPTION_ALIASES_ES.get(item_id, {}) if locale == "es" else {},
|
|
205
306
|
"writable": not bool(item.get("disabled")),
|
|
206
307
|
"storage": "personal_scripts.metadata.automation_preferences",
|
|
207
308
|
"path": item_id,
|
|
@@ -38,6 +38,7 @@ import signal
|
|
|
38
38
|
import subprocess
|
|
39
39
|
import sys
|
|
40
40
|
import tempfile
|
|
41
|
+
import unicodedata
|
|
41
42
|
import urllib.parse
|
|
42
43
|
import urllib.request
|
|
43
44
|
import xml.etree.ElementTree as ET
|
|
@@ -87,7 +88,42 @@ MAX_DIARY_ITEMS = 6
|
|
|
87
88
|
MORNING_BRIEFING_STALE_HOURS = 12
|
|
88
89
|
_ACTIVE_CLAIM: dict[str, str] = {}
|
|
89
90
|
HTTP_TIMEOUT = 7
|
|
90
|
-
|
|
91
|
+
NEWS_MAX_HEADLINES = 8
|
|
92
|
+
NEWS_MAX_FEEDS = 5
|
|
93
|
+
NEWS_INTEREST_QUERY_ES = {
|
|
94
|
+
"business": "empresa economia negocios",
|
|
95
|
+
"technology": "tecnologia inteligencia artificial software",
|
|
96
|
+
"finance": "finanzas economia mercados",
|
|
97
|
+
"local": "actualidad local",
|
|
98
|
+
"health": "salud sanidad medicina",
|
|
99
|
+
"legal": "legal justicia normativa",
|
|
100
|
+
"education": "educacion universidad formacion",
|
|
101
|
+
"real_estate": "vivienda inmobiliario urbanismo",
|
|
102
|
+
"science": "ciencia investigacion innovacion",
|
|
103
|
+
"culture": "cultura sociedad",
|
|
104
|
+
"sports": "deportes",
|
|
105
|
+
}
|
|
106
|
+
NEWS_INTEREST_QUERY_EN = {
|
|
107
|
+
"business": "business economy companies",
|
|
108
|
+
"technology": "technology artificial intelligence software",
|
|
109
|
+
"finance": "finance economy markets",
|
|
110
|
+
"local": "local news",
|
|
111
|
+
"health": "health medicine healthcare",
|
|
112
|
+
"legal": "law regulation justice",
|
|
113
|
+
"education": "education university training",
|
|
114
|
+
"real_estate": "housing real estate urban planning",
|
|
115
|
+
"science": "science research innovation",
|
|
116
|
+
"culture": "culture society",
|
|
117
|
+
"sports": "sports",
|
|
118
|
+
}
|
|
119
|
+
NEWS_EXCLUDED_KEYWORDS = {
|
|
120
|
+
"politics": ["politica", "politics", "eleccion", "election", "partido", "congreso", "senado", "parliament"],
|
|
121
|
+
"sports": ["deporte", "sports", "futbol", "football", "liga", "tenis", "basket", "baloncesto"],
|
|
122
|
+
"celebrity": ["famos", "celebrity", "celebrit", "television", "tv", "influencer"],
|
|
123
|
+
"crime": ["crimen", "crime", "asesin", "homicid", "robo", "suceso", "detenido", "arrest"],
|
|
124
|
+
"crypto": ["crypto", "cripto", "bitcoin", "ethereum", "blockchain", "nft"],
|
|
125
|
+
"market_noise": ["bolsa", "stock", "stocks", "market", "mercado", "wall street", "ibex", "nasdaq"],
|
|
126
|
+
}
|
|
91
127
|
|
|
92
128
|
|
|
93
129
|
def log(message: str) -> None:
|
|
@@ -654,31 +690,177 @@ def _collect_weather(profile: dict) -> dict:
|
|
|
654
690
|
return {"available": False, "error": str(exc)[:240]}
|
|
655
691
|
|
|
656
692
|
|
|
657
|
-
def
|
|
693
|
+
def _fold_match(value: object) -> str:
|
|
694
|
+
normalized = unicodedata.normalize("NFKD", str(value or ""))
|
|
695
|
+
asciiish = "".join(ch for ch in normalized if not unicodedata.combining(ch))
|
|
696
|
+
return asciiish.casefold()
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _preference_list(preferences: dict | None, key: str, default: list[str] | None = None) -> list[str]:
|
|
700
|
+
values = preferences if isinstance(preferences, dict) else {}
|
|
701
|
+
raw = values.get(key)
|
|
702
|
+
if isinstance(raw, (list, tuple, set)):
|
|
703
|
+
items = [str(item or "").strip() for item in raw]
|
|
704
|
+
elif raw:
|
|
705
|
+
items = [part.strip() for part in str(raw).replace(";", ",").split(",")]
|
|
706
|
+
else:
|
|
707
|
+
items = list(default or [])
|
|
708
|
+
result: list[str] = []
|
|
709
|
+
for item in items:
|
|
710
|
+
if item and item not in result:
|
|
711
|
+
result.append(item)
|
|
712
|
+
return result
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _news_locale(profile: dict) -> tuple[str, str]:
|
|
716
|
+
language = str(profile.get("language") or "").strip().lower()
|
|
717
|
+
residence = _fold_match(profile.get("current_residence") or profile.get("location") or "")
|
|
718
|
+
if language.startswith("en"):
|
|
719
|
+
return "en", "US"
|
|
720
|
+
if any(token in residence for token in ["united states", "usa", "eeuu", "estados unidos"]):
|
|
721
|
+
return "en", "US"
|
|
722
|
+
return "es", "ES"
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _news_feed_url(query: str, *, language: str, country: str) -> str:
|
|
726
|
+
lang = "en" if language.startswith("en") else "es"
|
|
727
|
+
params = {
|
|
728
|
+
"hl": lang,
|
|
729
|
+
"gl": country,
|
|
730
|
+
"ceid": f"{country}:{lang}",
|
|
731
|
+
}
|
|
732
|
+
clean_query = str(query or "").strip()
|
|
733
|
+
if clean_query:
|
|
734
|
+
params["q"] = clean_query
|
|
735
|
+
return f"https://news.google.com/rss/search?{urllib.parse.urlencode(params)}"
|
|
736
|
+
return f"https://news.google.com/rss?{urllib.parse.urlencode(params)}"
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _profile_news_terms(profile: dict) -> list[str]:
|
|
740
|
+
role = _fold_match(profile.get("role") or profile.get("profession") or profile.get("job_title") or "")
|
|
741
|
+
residence = str(profile.get("current_residence") or profile.get("location") or "").strip()
|
|
742
|
+
terms: list[str] = []
|
|
743
|
+
role_queries = [
|
|
744
|
+
(["fundador", "founder", "ceo", "directivo", "manager", "business"], "empresa economia tecnologia"),
|
|
745
|
+
(["developer", "programador", "software", "technical", "tecnico", "engineer"], "tecnologia software inteligencia artificial"),
|
|
746
|
+
(["medic", "doctor", "clinica", "health", "sanidad"], "salud sanidad medicina"),
|
|
747
|
+
(["arquitect", "architecture", "inmobili", "real estate"], "vivienda urbanismo arquitectura"),
|
|
748
|
+
(["abog", "law", "legal"], "legal normativa justicia"),
|
|
749
|
+
(["educ", "profesor", "student", "estudiante"], "educacion formacion universidad"),
|
|
750
|
+
(["ventas", "sales", "commercial", "comercial"], "ventas clientes empresa"),
|
|
751
|
+
(["administr", "admin", "office"], "empresa laboral administracion"),
|
|
752
|
+
]
|
|
753
|
+
for needles, query in role_queries:
|
|
754
|
+
if any(needle in role for needle in needles):
|
|
755
|
+
terms.append(query)
|
|
756
|
+
break
|
|
757
|
+
if residence:
|
|
758
|
+
terms.append(f"{residence} actualidad")
|
|
759
|
+
return terms
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _news_queries(profile: dict, preferences: dict | None) -> tuple[list[dict[str, str]], list[str], list[str]]:
|
|
763
|
+
language, country = _news_locale(profile)
|
|
764
|
+
interests = _preference_list(preferences, "news_interests", ["automatic"])
|
|
765
|
+
excluded = _preference_list(preferences, "excluded_topics", [])
|
|
766
|
+
automatic = not interests or "automatic" in interests
|
|
767
|
+
query_map = NEWS_INTEREST_QUERY_EN if language.startswith("en") else NEWS_INTEREST_QUERY_ES
|
|
768
|
+
queries: list[dict[str, str]] = []
|
|
769
|
+
|
|
770
|
+
if automatic:
|
|
771
|
+
for term in _profile_news_terms(profile):
|
|
772
|
+
queries.append({"interest": "automatic", "query": term})
|
|
773
|
+
fallback = "business technology economy" if language.startswith("en") else "empresa tecnologia economia"
|
|
774
|
+
queries.append({"interest": "automatic", "query": fallback})
|
|
775
|
+
|
|
776
|
+
for interest in interests:
|
|
777
|
+
if interest == "automatic":
|
|
778
|
+
continue
|
|
779
|
+
query = query_map.get(interest)
|
|
780
|
+
if not query:
|
|
781
|
+
continue
|
|
782
|
+
if interest == "local":
|
|
783
|
+
residence = str(profile.get("current_residence") or profile.get("location") or "").strip()
|
|
784
|
+
if residence:
|
|
785
|
+
query = f"{residence} actualidad"
|
|
786
|
+
queries.append({"interest": interest, "query": query})
|
|
787
|
+
|
|
788
|
+
if not queries:
|
|
789
|
+
queries.append({"interest": "automatic", "query": ""})
|
|
790
|
+
|
|
791
|
+
deduped: list[dict[str, str]] = []
|
|
792
|
+
seen: set[str] = set()
|
|
793
|
+
for query in queries:
|
|
794
|
+
key = _fold_match(query.get("query") or "") or "top"
|
|
795
|
+
if key in seen:
|
|
796
|
+
continue
|
|
797
|
+
seen.add(key)
|
|
798
|
+
deduped.append(query)
|
|
799
|
+
if len(deduped) >= NEWS_MAX_FEEDS:
|
|
800
|
+
break
|
|
801
|
+
return deduped, interests or ["automatic"], excluded
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _headline_is_excluded(title: str, excluded_topics: list[str]) -> bool:
|
|
805
|
+
folded = _fold_match(title)
|
|
806
|
+
for topic in excluded_topics:
|
|
807
|
+
for keyword in NEWS_EXCLUDED_KEYWORDS.get(topic, []):
|
|
808
|
+
if _fold_match(keyword) in folded:
|
|
809
|
+
return True
|
|
810
|
+
return False
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _parse_news_feed(xml_text: str, *, interest: str, excluded_topics: list[str]) -> list[dict]:
|
|
814
|
+
root = ET.fromstring(xml_text)
|
|
815
|
+
items: list[dict] = []
|
|
816
|
+
for item in root.findall(".//item"):
|
|
817
|
+
title = _clean_text(item.findtext("title"), limit=220)
|
|
818
|
+
if not title or _headline_is_excluded(title, excluded_topics):
|
|
819
|
+
continue
|
|
820
|
+
link = str(item.findtext("link") or "").strip()
|
|
821
|
+
source = _clean_text(item.findtext("source"), limit=80)
|
|
822
|
+
published = _clean_text(item.findtext("pubDate"), limit=120)
|
|
823
|
+
items.append({
|
|
824
|
+
"title": title,
|
|
825
|
+
"source": source,
|
|
826
|
+
"published": published,
|
|
827
|
+
"url": link[:500],
|
|
828
|
+
"interest": interest,
|
|
829
|
+
})
|
|
830
|
+
return items
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _collect_news(profile: dict, preferences: dict | None = None) -> dict:
|
|
658
834
|
try:
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
return {"available": False, "error": "no_news_source"}
|
|
662
|
-
xml_text = _fetch_text_url(rss_url)
|
|
663
|
-
root = ET.fromstring(xml_text)
|
|
835
|
+
language, country = _news_locale(profile)
|
|
836
|
+
queries, interests, excluded = _news_queries(profile, preferences)
|
|
664
837
|
items: list[dict] = []
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
838
|
+
seen_titles: set[str] = set()
|
|
839
|
+
used_queries: list[str] = []
|
|
840
|
+
for query in queries:
|
|
841
|
+
feed_url = _news_feed_url(query.get("query") or "", language=language, country=country)
|
|
842
|
+
xml_text = _fetch_text_url(feed_url)
|
|
843
|
+
used_queries.append(query.get("query") or "top headlines")
|
|
844
|
+
for headline in _parse_news_feed(xml_text, interest=query.get("interest") or "automatic", excluded_topics=excluded):
|
|
845
|
+
key = _fold_match(headline.get("title") or "")
|
|
846
|
+
if not key or key in seen_titles:
|
|
847
|
+
continue
|
|
848
|
+
seen_titles.add(key)
|
|
849
|
+
items.append(headline)
|
|
850
|
+
if len(items) >= NEWS_MAX_HEADLINES:
|
|
851
|
+
break
|
|
852
|
+
if len(items) >= NEWS_MAX_HEADLINES:
|
|
678
853
|
break
|
|
679
854
|
return {
|
|
680
855
|
"available": bool(items),
|
|
681
|
-
"source":
|
|
856
|
+
"source": "google-news-rss",
|
|
857
|
+
"mode": "relevant_public_context",
|
|
858
|
+
"language": language,
|
|
859
|
+
"country": country,
|
|
860
|
+
"interests": interests,
|
|
861
|
+
"excluded_topics": excluded,
|
|
862
|
+
"queries": used_queries,
|
|
863
|
+
"selection_rule": "Headlines are only useful if they relate to the operator's work, location, explicit interests or broad high-impact context.",
|
|
682
864
|
"headlines": items,
|
|
683
865
|
"error": "" if items else "empty_feed",
|
|
684
866
|
}
|
|
@@ -692,7 +874,7 @@ def _collect_external_context(profile: dict, preferences: dict | None) -> dict:
|
|
|
692
874
|
if values.get("weather"):
|
|
693
875
|
external["weather"] = _collect_weather(profile)
|
|
694
876
|
if values.get("news"):
|
|
695
|
-
external["news"] = _collect_news(profile)
|
|
877
|
+
external["news"] = _collect_news(profile, values)
|
|
696
878
|
return external
|
|
697
879
|
|
|
698
880
|
|
|
@@ -9,7 +9,9 @@ Product intent:
|
|
|
9
9
|
- Think like a professional personal assistant or chief of staff: filter noise, rank priorities, connect related items, and make the day feel prepared.
|
|
10
10
|
- Adapt the emphasis to the operator's real context: administrative work needs tasks, deadlines, email movement and missing inputs; executives need decisions, risks, money, people and blocked outcomes; technical users need incidents, deployments, regressions and dependencies; commercial users need leads, customers and follow-ups; regulated or clinical users need careful wording, pending actions and factual status only.
|
|
11
11
|
- Do not ask the operator to choose a user type. Infer the useful angle from the structured context, profile fields, recent activity and explicit preferences. If the context is thin, stay general and practical.
|
|
12
|
-
- Include news and weather only when verified collected data exists in the context.
|
|
12
|
+
- Include news and weather only when verified collected data exists in the context. Public headlines are not a generic news block: include them only when they relate to the operator's work, location, declared interests, or broad high-impact context worth knowing before starting the day.
|
|
13
|
+
- When external context is included, make it useful in one sentence. Do not mention RSS feeds, internal URLs, settings schemas, or source plumbing.
|
|
14
|
+
- If the structured preferences include automatic relevance, actively omit low-value items even when a source is enabled.
|
|
13
15
|
|
|
14
16
|
Hard rules:
|
|
15
17
|
- Do not invent achievements, blockers, meetings, messages, or external events.
|
|
@@ -20,6 +22,14 @@ Hard rules:
|
|
|
20
22
|
- Mention operator decisions only when the context actually supports them.
|
|
21
23
|
- Keep the email concise unless structured preferences ask for more detail: roughly 180-350 words.
|
|
22
24
|
- Use short sections and bullets when useful.
|
|
25
|
+
|
|
26
|
+
Recommended structure:
|
|
27
|
+
- Opening: one short sentence with the state of the day.
|
|
28
|
+
- Top priorities: the 1-3 things most worth attention.
|
|
29
|
+
- Changes and commitments: only what moved, is due, or affects today's choices.
|
|
30
|
+
- Risks or blockers: only if there is something to prevent, unblock, or decide.
|
|
31
|
+
- Day context: weather and relevant public context only when verified and useful.
|
|
32
|
+
- Next actions: practical first steps when the context supports them.
|
|
23
33
|
[[extra_section]]Return ONLY a valid JSON object with this exact shape:
|
|
24
34
|
{
|
|
25
35
|
"subject": "string",
|