fenix-mcp 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,220 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Productivity tool implementation (TODO operations)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from enum import Enum
7
+ from typing import Any, Dict, Iterable, List, Optional
8
+
9
+ from pydantic import Field
10
+
11
+ from fenix_mcp.application.presenters import text
12
+ from fenix_mcp.application.tool_base import Tool, ToolRequest
13
+ from fenix_mcp.domain.productivity import ProductivityService
14
+ from fenix_mcp.infrastructure.context import AppContext
15
+
16
+
17
+ class TodoAction(str, Enum):
18
+ def __new__(cls, value: str, description: str):
19
+ obj = str.__new__(cls, value)
20
+ obj._value_ = value
21
+ obj.description = description
22
+ return obj
23
+
24
+ CREATE = ("todo_create", "Cria um novo TODO.")
25
+ LIST = ("todo_list", "Lista TODOs com filtros opcionais.")
26
+ GET = ("todo_get", "Obtém detalhes de um TODO pelo ID.")
27
+ UPDATE = ("todo_update", "Atualiza campos de um TODO existente.")
28
+ DELETE = ("todo_delete", "Remove um TODO pelo ID.")
29
+ STATS = ("todo_stats", "Retorna estatísticas agregadas de TODOs.")
30
+ SEARCH = ("todo_search", "Busca TODOs por termo textual.")
31
+ OVERDUE = ("todo_overdue", "Lista TODOs atrasados.")
32
+ UPCOMING = ("todo_upcoming", "Lista TODOs com vencimento próximo.")
33
+ CATEGORIES = ("todo_categories", "Lista categorias registradas.")
34
+ TAGS = ("todo_tags", "Lista tags registradas.")
35
+ HELP = ("todo_help", "Mostra as ações suportadas e seus usos.")
36
+
37
+ @classmethod
38
+ def choices(cls) -> List[str]:
39
+ return [member.value for member in cls]
40
+
41
+ @classmethod
42
+ def formatted_help(cls) -> str:
43
+ lines = [
44
+ "| **Ação** | **Descrição** |",
45
+ "| --- | --- |",
46
+ ]
47
+ for member in cls:
48
+ lines.append(f"| `{member.value}` | {member.description} |")
49
+ return "\n".join(lines)
50
+
51
+
52
+ ACTION_FIELD_DESCRIPTION = (
53
+ "Ação de produtividade (TODO). Escolha um dos valores: "
54
+ + ", ".join(f"`{member.value}` ({member.description.rstrip('.')})." for member in TodoAction)
55
+ )
56
+
57
+
58
+ class ProductivityRequest(ToolRequest):
59
+ action: TodoAction = Field(description=ACTION_FIELD_DESCRIPTION)
60
+ id: Optional[str] = Field(default=None, description="Identificador do item TODO.")
61
+ title: Optional[str] = Field(default=None, description="Título do TODO (obrigatório em create).")
62
+ content: Optional[str] = Field(default=None, description="Conteúdo em Markdown (obrigatório em create).")
63
+ status: Optional[str] = Field(default="pending", description="Status do TODO.")
64
+ priority: Optional[str] = Field(default="medium", description="Prioridade do TODO.")
65
+ category: Optional[str] = Field(default=None, description="Categoria opcional.")
66
+ tags: Optional[List[str]] = Field(default=None, description="Lista de tags.")
67
+ due_date: Optional[str] = Field(default=None, description="Data de vencimento do TODO (ISO).")
68
+ limit: int = Field(default=20, ge=1, le=100, description="Limite de resultados em list/search.")
69
+ offset: int = Field(default=0, ge=0, description="Offset de paginação.")
70
+ query: Optional[str] = Field(default=None, description="Termo de busca.")
71
+ days: Optional[int] = Field(default=None, ge=1, le=30, description="Janela de dias para upcoming.")
72
+
73
+
74
+ class ProductivityTool(Tool):
75
+ name = "productivity"
76
+ description = "Operações de produtividade do Fênix Cloud (TODOs)."
77
+ request_model = ProductivityRequest
78
+
79
+ def __init__(self, context: AppContext):
80
+ self._context = context
81
+ self._service = ProductivityService(context.api_client, context.logger)
82
+
83
+ async def run(self, payload: ProductivityRequest, context: AppContext):
84
+ action = payload.action
85
+ if action is TodoAction.HELP:
86
+ return await self._handle_help()
87
+ if action is TodoAction.CREATE:
88
+ return await self._handle_create(payload)
89
+ if action is TodoAction.LIST:
90
+ return await self._handle_list(payload)
91
+ if action is TodoAction.GET:
92
+ return await self._handle_get(payload)
93
+ if action is TodoAction.UPDATE:
94
+ return await self._handle_update(payload)
95
+ if action is TodoAction.DELETE:
96
+ return await self._handle_delete(payload)
97
+ if action is TodoAction.STATS:
98
+ return await self._handle_stats()
99
+ if action is TodoAction.SEARCH:
100
+ return await self._handle_search(payload)
101
+ if action is TodoAction.OVERDUE:
102
+ return await self._handle_overdue()
103
+ if action is TodoAction.UPCOMING:
104
+ return await self._handle_upcoming(payload)
105
+ if action is TodoAction.CATEGORIES:
106
+ return await self._handle_categories()
107
+ if action is TodoAction.TAGS:
108
+ return await self._handle_tags()
109
+ return text(
110
+ "❌ Ação inválida para productivity.\n\nEscolha um dos valores:\n"
111
+ + "\n".join(f"- `{value}`" for value in TodoAction.choices())
112
+ )
113
+
114
+ async def _handle_create(self, payload: ProductivityRequest):
115
+ if not payload.title or not payload.content or not payload.due_date:
116
+ return text("❌ Forneça título, conteúdo e due_date para criar um TODO.")
117
+ todo = await self._service.create_todo(
118
+ title=payload.title,
119
+ content=payload.content,
120
+ status=payload.status or "pending",
121
+ priority=payload.priority or "medium",
122
+ category=payload.category,
123
+ tags=payload.tags or [],
124
+ due_date=payload.due_date,
125
+ )
126
+ return text(self._format_single(todo, header="✅ TODO criado com sucesso!"))
127
+
128
+ async def _handle_list(self, payload: ProductivityRequest):
129
+ todos = await self._service.list_todos(
130
+ limit=payload.limit,
131
+ offset=payload.offset,
132
+ status=payload.status,
133
+ priority=payload.priority,
134
+ category=payload.category,
135
+ )
136
+ if not todos:
137
+ return text("📋 Nenhum TODO encontrado.")
138
+ body = "\n\n".join(ProductivityService.format_todo(todo) for todo in todos)
139
+ return text(f"📋 **TODOs ({len(todos)}):**\n\n{body}")
140
+
141
+ async def _handle_get(self, payload: ProductivityRequest):
142
+ if not payload.id:
143
+ return text("❌ Informe o ID para consultar um TODO.")
144
+ todo = await self._service.get_todo(payload.id)
145
+ return text(self._format_single(todo, header="📋 TODO encontrado"))
146
+
147
+ async def _handle_update(self, payload: ProductivityRequest):
148
+ if not payload.id:
149
+ return text("❌ Informe o ID para atualizar um TODO.")
150
+ fields = {
151
+ "title": payload.title,
152
+ "content": payload.content,
153
+ "status": payload.status,
154
+ "priority": payload.priority,
155
+ "category": payload.category,
156
+ "tags": payload.tags,
157
+ "due_date": payload.due_date,
158
+ }
159
+ todo = await self._service.update_todo(payload.id, **fields)
160
+ return text(self._format_single(todo, header="✅ TODO atualizado"))
161
+
162
+ async def _handle_delete(self, payload: ProductivityRequest):
163
+ if not payload.id:
164
+ return text("❌ Informe o ID para excluir um TODO.")
165
+ await self._service.delete_todo(payload.id)
166
+ return text(f"🗑️ TODO {payload.id} removido com sucesso.")
167
+
168
+ async def _handle_stats(self):
169
+ stats = await self._service.stats()
170
+ lines = ["📊 **Estatísticas de TODOs**"]
171
+ for key, value in (stats or {}).items():
172
+ lines.append(f"- {key}: {value}")
173
+ return text("\n".join(lines))
174
+
175
+ async def _handle_search(self, payload: ProductivityRequest):
176
+ if not payload.query:
177
+ return text("❌ Informe um termo de busca (query).")
178
+ todos = await self._service.search(payload.query, limit=payload.limit, offset=payload.offset)
179
+ if not todos:
180
+ return text("🔍 Nenhum TODO encontrado para a busca.")
181
+ body = "\n\n".join(ProductivityService.format_todo(todo) for todo in todos)
182
+ return text(f"🔍 **Resultados da busca ({len(todos)}):**\n\n{body}")
183
+
184
+ async def _handle_overdue(self):
185
+ todos = await self._service.overdue()
186
+ if not todos:
187
+ return text("✅ Sem TODOs atrasados no momento.")
188
+ body = "\n\n".join(ProductivityService.format_todo(todo) for todo in todos)
189
+ return text(f"⏰ **TODOs atrasados ({len(todos)}):**\n\n{body}")
190
+
191
+ async def _handle_upcoming(self, payload: ProductivityRequest):
192
+ todos = await self._service.upcoming(days=payload.days)
193
+ if not todos:
194
+ return text("📅 Nenhum TODO previsto para o período informado.")
195
+ body = "\n\n".join(ProductivityService.format_todo(todo) for todo in todos)
196
+ header = f"📅 TODOs programados ({len(todos)}):"
197
+ if payload.days:
198
+ header += f" próximos {payload.days} dias"
199
+ return text(f"{header}\n\n{body}")
200
+
201
+ async def _handle_categories(self):
202
+ categories = await self._service.categories()
203
+ if not categories:
204
+ return text("🏷️ Nenhuma categoria registrada ainda.")
205
+ body = "\n".join(f"- {category}" for category in categories)
206
+ return text(f"🏷️ **Categorias utilizadas:**\n{body}")
207
+
208
+ async def _handle_tags(self):
209
+ tags = await self._service.tags()
210
+ if not tags:
211
+ return text("🔖 Nenhuma tag registrada ainda.")
212
+ body = "\n".join(f"- {tag}" for tag in tags)
213
+ return text(f"🔖 **Tags utilizadas:**\n{body}")
214
+
215
+ async def _handle_help(self):
216
+ return text("📚 **Ações disponíveis para productivity**\n\n" + TodoAction.formatted_help())
217
+
218
+ @staticmethod
219
+ def _format_single(todo: Dict[str, Any], *, header: str) -> str:
220
+ return "\n".join([header, "", ProductivityService.format_todo(todo)])
@@ -0,0 +1,158 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """User configuration tool implementation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from enum import Enum
7
+ from typing import Dict, List, Optional
8
+
9
+ from pydantic import Field
10
+
11
+ from fenix_mcp.application.presenters import text
12
+ from fenix_mcp.application.tool_base import Tool, ToolRequest
13
+ from fenix_mcp.domain.user_config import UserConfigService, _strip_none
14
+ from fenix_mcp.infrastructure.context import AppContext
15
+
16
+
17
+ class UserConfigAction(str, Enum):
18
+ def __new__(cls, value: str, description: str):
19
+ obj = str.__new__(cls, value)
20
+ obj._value_ = value
21
+ obj.description = description
22
+ return obj
23
+
24
+ CREATE = ("create", "Cria um novo user core document.")
25
+ LIST = ("list", "Lista documentos com paginação opcional.")
26
+ GET = ("get", "Obtém detalhes de um documento específico.")
27
+ UPDATE = ("update", "Atualiza campos de um documento existente.")
28
+ DELETE = ("delete", "Remove um documento.")
29
+ HELP = ("help", "Mostra as ações disponíveis e seus usos.")
30
+
31
+ @classmethod
32
+ def choices(cls) -> List[str]:
33
+ return [member.value for member in cls]
34
+
35
+ @classmethod
36
+ def formatted_help(cls) -> str:
37
+ lines = [
38
+ "| **Ação** | **Descrição** |",
39
+ "| --- | --- |",
40
+ ]
41
+ for member in cls:
42
+ lines.append(f"| `{member.value}` | {member.description} |")
43
+ return "\n".join(lines)
44
+
45
+
46
+ ACTION_FIELD_DESCRIPTION = (
47
+ "Ação a executar. Escolha um dos valores: "
48
+ + ", ".join(f"`{member.value}` ({member.description.rstrip('.')})." for member in UserConfigAction)
49
+ )
50
+
51
+
52
+ class UserConfigRequest(ToolRequest):
53
+ action: UserConfigAction = Field(description=ACTION_FIELD_DESCRIPTION)
54
+ id: Optional[str] = Field(default=None, description="ID do documento.")
55
+ name: Optional[str] = Field(default=None, description="Nome do documento.")
56
+ content: Optional[str] = Field(default=None, description="Conteúdo em Markdown/JSON.")
57
+ mode_id: Optional[str] = Field(default=None, description="ID do modo associado.")
58
+ is_default: Optional[bool] = Field(default=None, description="Marca o documento como padrão.")
59
+ limit: int = Field(default=20, ge=1, le=100, description="Limite para listagem.")
60
+ offset: int = Field(default=0, ge=0, description="Offset para listagem.")
61
+ return_content: Optional[bool] = Field(default=None, description="Retorna conteúdo completo.")
62
+
63
+
64
+ class UserConfigTool(Tool):
65
+ name = "user_config"
66
+ description = "Gerencia documentos de configuração do usuário (Core Documents)."
67
+ request_model = UserConfigRequest
68
+
69
+ def __init__(self, context: AppContext):
70
+ self._context = context
71
+ self._service = UserConfigService(context.api_client)
72
+
73
+ async def run(self, payload: UserConfigRequest, context: AppContext):
74
+ action = payload.action
75
+ if action is UserConfigAction.HELP:
76
+ return await self._handle_help()
77
+ if action is UserConfigAction.CREATE:
78
+ if not payload.name or not payload.content:
79
+ return text("❌ Informe name e content para criar o documento.")
80
+ doc = await self._service.create(
81
+ _strip_none(
82
+ {
83
+ "name": payload.name,
84
+ "content": payload.content,
85
+ "mode_id": payload.mode_id,
86
+ "is_default": payload.is_default,
87
+ }
88
+ )
89
+ )
90
+ return text(_format_doc(doc, header="✅ Documento criado"))
91
+
92
+ if action is UserConfigAction.LIST:
93
+ docs = await self._service.list(
94
+ limit=payload.limit,
95
+ offset=payload.offset,
96
+ returnContent=payload.return_content,
97
+ )
98
+ if not docs:
99
+ return text("📂 Nenhum documento encontrado.")
100
+ body = "\n\n".join(_format_doc(doc) for doc in docs)
101
+ return text(f"📂 **Documentos ({len(docs)}):**\n\n{body}")
102
+
103
+ if action is UserConfigAction.GET:
104
+ if not payload.id:
105
+ return text("❌ Informe o ID do documento.")
106
+ doc = await self._service.get(
107
+ payload.id,
108
+ returnContent=payload.return_content,
109
+ )
110
+ return text(_format_doc(doc, header="📂 Detalhes do documento"))
111
+
112
+ if action is UserConfigAction.UPDATE:
113
+ if not payload.id:
114
+ return text("❌ Informe o ID do documento para atualizar.")
115
+ data = _strip_none(
116
+ {
117
+ "name": payload.name,
118
+ "content": payload.content,
119
+ "mode_id": payload.mode_id,
120
+ "is_default": payload.is_default,
121
+ }
122
+ )
123
+ doc = await self._service.update(payload.id, data)
124
+ return text(_format_doc(doc, header="✅ Documento atualizado"))
125
+
126
+ if action is UserConfigAction.DELETE:
127
+ if not payload.id:
128
+ return text("❌ Informe o ID do documento." )
129
+ await self._service.delete(payload.id)
130
+ return text(f"🗑️ Documento {payload.id} removido.")
131
+
132
+ return text(
133
+ "❌ Ação de user_config não suportada.\n\nEscolha um dos valores:\n"
134
+ + "\n".join(f"- `{value}`" for value in UserConfigAction.choices())
135
+ )
136
+
137
+ async def _handle_help(self):
138
+ return text("📚 **Ações disponíveis para user_config**\n\n" + UserConfigAction.formatted_help())
139
+
140
+
141
+ def _format_doc(doc: Dict[str, Any], header: Optional[str] = None) -> str:
142
+ lines: List[str] = []
143
+ if header:
144
+ lines.append(header)
145
+ lines.append("")
146
+ lines.extend(
147
+ [
148
+ f"📂 **{doc.get('name', 'Sem nome')}**",
149
+ f"ID: {doc.get('id', 'N/A')}",
150
+ f"Default: {doc.get('is_default', False)}",
151
+ ]
152
+ )
153
+ if doc.get("mode_id"):
154
+ lines.append(f"Modo associado: {doc['mode_id']}")
155
+ if doc.get("content") and len(doc["content"]) <= 400:
156
+ lines.append("")
157
+ lines.append(doc["content"])
158
+ return "\n".join(lines)
@@ -0,0 +1,180 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Domain helpers for initialization operations."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from dataclasses import dataclass
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from fenix_mcp.infrastructure.fenix_api.client import FenixApiClient
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class InitializationData:
15
+ profile: Optional[Dict[str, Any]]
16
+ core_documents: List[Dict[str, Any]]
17
+ user_documents: List[Dict[str, Any]]
18
+ recent_memories: List[Dict[str, Any]]
19
+
20
+
21
+ class InitializationService:
22
+ """Fetch and format initialization data from the Fênix API."""
23
+
24
+ def __init__(self, api_client: FenixApiClient, logger):
25
+ self._api = api_client
26
+ self._logger = logger
27
+
28
+ async def gather_data(self, *, include_user_docs: bool, limit: int) -> InitializationData:
29
+ profile = await self._safe_call(self._api.get_profile)
30
+ core_docs = await self._safe_call(
31
+ self._api.list_core_documents,
32
+ return_content=True,
33
+ )
34
+ if self._logger:
35
+ self._logger.debug("Core docs response", extra={"core_docs": core_docs})
36
+ user_docs: List[Dict[str, Any]] = []
37
+ if include_user_docs:
38
+ user_docs = await self._safe_call(
39
+ self._api.list_user_core_documents,
40
+ return_content=True,
41
+ ) or []
42
+ if self._logger:
43
+ self._logger.debug("User docs response", extra={"user_docs": user_docs})
44
+ memories = await self._safe_call(
45
+ self._api.list_memories,
46
+ include_content=True,
47
+ include_metadata=False,
48
+ limit=3,
49
+ offset=0,
50
+ sortBy="created_at",
51
+ sortOrder="desc",
52
+ )
53
+ if self._logger:
54
+ self._logger.debug("Memories response", extra={"memories": memories})
55
+
56
+ return InitializationData(
57
+ profile=profile,
58
+ core_documents=self._extract_items(core_docs, "coreDocuments"),
59
+ user_documents=self._extract_items(user_docs, "userCoreDocuments"),
60
+ recent_memories=self._extract_items(memories, "memories"),
61
+ )
62
+
63
+ async def _safe_call(self, func, *args, **kwargs):
64
+ try:
65
+ return await asyncio.to_thread(func, *args, **kwargs)
66
+ except Exception as exc: # pragma: no cover - defensive logging path
67
+ self._logger.warning("Initialization API call failed: %s", exc)
68
+ return None
69
+
70
+ @staticmethod
71
+ def _extract_items(payload: Any, key: str) -> List[Dict[str, Any]]:
72
+ if payload is None:
73
+ return []
74
+ if isinstance(payload, list):
75
+ return [item for item in payload if isinstance(item, dict)]
76
+ if isinstance(payload, dict):
77
+ value = payload.get(key) or payload.get("data")
78
+ if isinstance(value, list):
79
+ return [item for item in value if isinstance(item, dict)]
80
+ return []
81
+
82
+ @staticmethod
83
+ def build_existing_user_summary(data: InitializationData, include_user_docs: bool) -> str:
84
+ profile = data.profile or {}
85
+ user_info = profile.get("user") or {}
86
+ tenant_info = profile.get("tenant") or {}
87
+ core_count = len(data.core_documents)
88
+ user_count = len(data.user_documents)
89
+ memories_count = len(data.recent_memories)
90
+
91
+ lines = [
92
+ "✅ **Fênix Cloud inicializado com sucesso!**",
93
+ ]
94
+
95
+ if user_info:
96
+ lines.append(
97
+ f"- Usuário: {user_info.get('name') or 'Desconhecido'} "
98
+ f"(ID: {user_info.get('id', 'N/A')})"
99
+ )
100
+ if tenant_info:
101
+ lines.append(f"- Organização: {tenant_info.get('name', 'N/A')}")
102
+
103
+ lines.append(f"- Documentos principais carregados: {core_count}")
104
+
105
+ if include_user_docs:
106
+ lines.append(f"- Documentos pessoais carregados: {user_count}")
107
+
108
+ lines.append(f"- Memórias recentes disponíveis: {memories_count}")
109
+
110
+ if core_count:
111
+ preview = ", ".join(
112
+ doc.get("name", doc.get("title", "sem título")) for doc in data.core_documents[:5]
113
+ )
114
+ lines.append(f"- Exemplos de documentos principais: {preview}")
115
+
116
+ if include_user_docs and user_count:
117
+ preview = ", ".join(doc.get("name", "sem título") for doc in data.user_documents[:5])
118
+ lines.append(f"- Exemplos de documentos pessoais: {preview}")
119
+
120
+ if memories_count:
121
+ preview = ", ".join(mem.get("title", "sem título") for mem in data.recent_memories[:3])
122
+ lines.append(f"- Memórias recentes: {preview}")
123
+
124
+ lines.append("")
125
+ lines.append(
126
+ "Agora você pode usar as ferramentas de produtividade, conhecimento e inteligência para continuar."
127
+ )
128
+ return "\n".join(lines)
129
+
130
+ @staticmethod
131
+ def build_new_user_prompt(data: InitializationData) -> str:
132
+ profile = data.profile or {}
133
+ user_name = (profile.get("user") or {}).get("name") or "Bem-vindo(a)"
134
+
135
+ questions = [
136
+ "Qual é o foco principal do seu trabalho atualmente?",
137
+ "Quais são seus objetivos para as próximas semanas?",
138
+ "Existem tarefas ou projetos que gostaria de priorizar?",
139
+ "Como você prefere organizar suas rotinas ou checklists?",
140
+ "Quais são os principais blocos de conhecimento que precisa acessar rapidamente?",
141
+ "Há regras ou procedimentos que precisam ser seguidos com frequência?",
142
+ "Que tipo de memória ou registro você consulta com frequência?",
143
+ "Quais ferramentas ou integrações externas são essenciais no seu dia a dia?",
144
+ "O que significaria sucesso para você utilizando o Fênix?",
145
+ ]
146
+
147
+ body = [
148
+ f"👋 **{user_name}, bem-vindo(a) ao Fênix Cloud!**",
149
+ "",
150
+ "Notamos que você ainda não tem documentos pessoais configurados. "
151
+ "Vamos criar uma experiência personalizada para você.",
152
+ "",
153
+ "Responda às perguntas abaixo para que possamos preparar modos, regras e documentos alinhados às suas necessidades:",
154
+ "",
155
+ ]
156
+
157
+ body.extend(f"{idx + 1}. {question}" for idx, question in enumerate(questions))
158
+ body.extend(
159
+ [
160
+ "",
161
+ "Envie as respostas no formato:",
162
+ "```",
163
+ 'initialize action=setup answers=["resposta 1", "resposta 2", ..., "resposta 9"]',
164
+ "```",
165
+ "",
166
+ "Com base nisso, sugerirei documentos, regras e rotinas para você utilizar.",
167
+ ]
168
+ )
169
+
170
+ return "\n".join(body)
171
+
172
+ @staticmethod
173
+ def validate_setup_answers(answers: List[str]) -> Optional[str]:
174
+ if not answers:
175
+ return "Envie uma lista com 9 respostas para processarmos o setup."
176
+ if len(answers) != 9:
177
+ return f"Esperado receber 9 respostas, mas recebi {len(answers)}."
178
+ if not all(isinstance(answer, str) and answer.strip() for answer in answers):
179
+ return "Todas as respostas devem ser textos preenchidos."
180
+ return None
@@ -0,0 +1,133 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Domain helpers for intelligence (memory) operations."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from dataclasses import dataclass
8
+ from typing import Any, Dict, Iterable, List, Optional
9
+
10
+ from fenix_mcp.infrastructure.fenix_api.client import FenixApiClient
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class IntelligenceService:
15
+ api: FenixApiClient
16
+ logger: Any
17
+
18
+ async def smart_create_memory(
19
+ self,
20
+ *,
21
+ title: str,
22
+ content: str,
23
+ context: Optional[str],
24
+ source: Optional[str],
25
+ importance: str,
26
+ tags: Optional[Iterable[str]] = None,
27
+ ) -> Dict[str, Any]:
28
+ metadata_parts: List[str] = []
29
+ if context:
30
+ metadata_parts.append(f"Contexto: {context}")
31
+ if source:
32
+ metadata_parts.append(f"Fonte: {source}")
33
+
34
+ payload = {
35
+ "title": title,
36
+ "content": content,
37
+ "metadata": "\n".join(metadata_parts) if metadata_parts else None,
38
+ "priority_score": _importance_to_priority(importance),
39
+ "tags": list(tags) if tags else None,
40
+ }
41
+ return await self._call(self.api.smart_create_memory, _strip_none(payload))
42
+
43
+ async def query_memories(self, **filters: Any) -> List[Dict[str, Any]]:
44
+ params = _strip_none(filters)
45
+ include_content = bool(params.pop("content", True))
46
+ include_metadata = bool(params.pop("metadata", False))
47
+ return await self._call(
48
+ self.api.list_memories,
49
+ include_content=include_content,
50
+ include_metadata=include_metadata,
51
+ **params,
52
+ ) or []
53
+
54
+ async def similar_memories(
55
+ self, *, content: str, threshold: float, max_results: int
56
+ ) -> List[Dict[str, Any]]:
57
+ payload = {
58
+ "content": content,
59
+ "threshold": threshold,
60
+ }
61
+ result = await self._call(self.api.find_similar_memories, _strip_none(payload)) or []
62
+ if isinstance(result, list) and max_results:
63
+ return result[:max_results]
64
+ return result
65
+
66
+ async def consolidate_memories(
67
+ self, *, memory_ids: Iterable[str], strategy: str
68
+ ) -> Dict[str, Any]:
69
+ payload = {
70
+ "memoryIds": list(memory_ids),
71
+ "strategy": strategy,
72
+ }
73
+ return await self._call(self.api.consolidate_memories, payload)
74
+
75
+ async def priority_memories(self, *, limit: int) -> List[Dict[str, Any]]:
76
+ params = {
77
+ "limit": limit,
78
+ "sortBy": "priority_score",
79
+ "sortOrder": "desc",
80
+ }
81
+ return await self._call(
82
+ self.api.list_memories,
83
+ include_content=False,
84
+ include_metadata=False,
85
+ **params,
86
+ ) or []
87
+
88
+ async def analytics(self, *, time_range: str, group_by: str) -> Dict[str, Any]:
89
+ memories = await self.query_memories(limit=200, timeRange=time_range, groupBy=group_by)
90
+ summary: Dict[str, Any] = {
91
+ "total_memories": len(memories),
92
+ "by_group": {},
93
+ }
94
+ group_key = group_by
95
+ for memory in memories:
96
+ key = memory.get(group_key) or memory.get("metadata") or "N/A"
97
+ summary["by_group"][key] = summary["by_group"].get(key, 0) + 1
98
+ return summary
99
+
100
+ async def update_memory(self, memory_id: str, **fields: Any) -> Dict[str, Any]:
101
+ payload = _strip_none(fields)
102
+ if "importance" in payload:
103
+ payload["priority_score"] = _importance_to_priority(payload.pop("importance"))
104
+ mapping = {
105
+ "documentation_item_id": "documentationItemId",
106
+ "mode_id": "modeId",
107
+ "rule_id": "ruleId",
108
+ "work_item_id": "workItemId",
109
+ "sprint_id": "sprintId",
110
+ }
111
+ for old_key, new_key in mapping.items():
112
+ if old_key in payload:
113
+ payload[new_key] = payload.pop(old_key)
114
+ return await self._call(self.api.update_memory, memory_id, payload)
115
+
116
+ async def _call(self, func, *args, **kwargs):
117
+ return await asyncio.to_thread(func, *args, **kwargs)
118
+
119
+
120
+ def _importance_to_priority(importance: Optional[str]) -> float:
121
+ mapping = {
122
+ "low": 0.2,
123
+ "medium": 0.5,
124
+ "high": 0.7,
125
+ "critical": 0.9,
126
+ }
127
+ if importance is None:
128
+ return 0.5
129
+ return mapping.get(importance.lower(), 0.5)
130
+
131
+
132
+ def _strip_none(data: Dict[str, Any]) -> Dict[str, Any]:
133
+ return {key: value for key, value in data.items() if value not in (None, "")}