File size: 6,461 Bytes
aec8693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# 📊 FLUXOGRAMA.md — Para.AI Assuntos Jurídicos
**Fluxo de dados nas pesquisas disponíveis**

---

## Fluxo 1 — GET /busca (full-text)

```
Cliente
  │  GET /busca?q=aposentadoria&ramo=DIREITO+PREVIDENCIÁRIO&size=10

routes.busca_get()
  │  valida Query params via FastAPI/Pydantic

es_client.buscar(q, ramo, nivel2, nivel3, lei, page, size, com_facets)

  ├─ build_busca_query()
  │    multi_match:
  │      query: "aposentadoria"
  │      fields: [nome_assunto^4, titulo_curto^3, breve_sintese^2,
  │               glossario, classes_path^2, texto_completo]
  │      fuzziness: AUTO · prefix_length: 2
  │    filter: [term{ramo: "DIREITO PREVIDENCIÁRIO"}]
  │    highlight: [nome_assunto, titulo_curto, breve_sintese]
  │    _source.excludes: [texto_completo]
  │    aggs: {por_ramo, por_nivel2, por_nivel3, por_lei, profundidades}
  │    from: 0 · size: 10


Elasticsearch 8.12
  │  Analyzer juridico_pt: tokenize → lowercase → asciifolding → stem PT
  │  BM25 scoring com boosts
  │  Agregações bucket

builders.build_busca_response(raw, took_ms, page, size)
  │  hits → AssuntoHit(score, Assunto(**src))
  │  highlight injetado em breve_sintese
  │  aggs → Facets(por_ramo, por_nivel2 …)

BuscaResponse {total, pagina, tamanho, took_ms, resultados[], facets}
  │  ORJSONResponse

Cliente recebe JSON
```

---

## Fluxo 2 — POST /busca-q (estruturada para LLMs)

```
LLM / Cliente
  │  POST /busca-q
  │  {q, campos:[{campo,valor}…], modo, topk, operador, retornar[]}

routes.busca_q_post()
  │  valida BuscaQRequest (Pydantic)
  │  valida retornar[] ∈ FICHA_CAMPOS_VALIDOS

es_client.busca_q(q, campos, modo, topk, operador, …, retornar)

  ├─ _ficha_to_es_source(retornar, incluir_texto_completo)
  │    retornar=[]  → {"excludes":["texto_completo"]}
  │    retornar=[…] → {"includes":[campos_es mapeados]}
  │    "texto" em retornar OR incluir_texto_completo=True
  │              → inclui texto_completo

  ├─ build_busca_q_query(q, campos, modo, topk, …)
  │    must:   multi_match(q) com pesos
  │    should: _clausula_campo(campo, valor, modo) × N
  │      fuzzy  → match com fuzziness AUTO
  │      exato  → term (keyword)
  │    minimum_should_match: 1 (or) | N (and)
  │    _source: ← _ficha_to_es_source()
  │    size: topk


Elasticsearch 8.12
  │  score combinado: must (q) + should (campos)
  │  retorna apenas campos _source solicitados

builders.build_busca_q_response(raw, …, retornar)
  │  hits → FichaHit(score, campos_matched, _src_to_ficha())

  │  _src_to_ficha():   ← FIX #1 aplicado aqui
  │    want = set(retornar)|{"id"} se retornar else None
  │    _want(campo) = True se want is None OR campo in want
  │    titulo      ← hl("titulo",       "nome_assunto")
  │    introducao  ← hl("introducao",   "breve_sintese")
  │    definicao   ← hl("definicao",    "glossario")
  │    normas      ← src["dispositivos_legais"]
  │    texto       ← src["texto_completo"]
  │                  SE (incluir_texto OR
  │                      (want is not None AND "texto" in want))


BuscaQResponse {total, retornados, took_ms, modo, operador, resultados[]}
  │  payload compacto (~2KB)  ← ideal para LLMs

LLM recebe fichas estruturadas
```

---

## Fluxo 3 — GET /autocomplete

```
Interface (digitação)
  │  GET /autocomplete?q=aposen&size=8

routes.autocomplete()

es_client.autocomplete(q, size)

  ├─ build_autocomplete_query("aposen", 8)
  │    _source: [nome_assunto, titulo_curto, ramo, classes_nivel2]
  │    bool.should:
  │      match{nome_assunto.autocomplete: "aposen", boost:2}
  │      match{titulo_curto.autocomplete: "aposen",  boost:1}


Elasticsearch
  │  Tokenizer edge_ngram: min_gram=2, max_gram=20
  │  "aposentadoria" → ["ap","apo","apos","aposen","aposent",…]
  │  Match: docs cujo prefixo == "aposen"
  │  Rank: boost nome_assunto > titulo_curto

Deduplicação (set) + prioriza nome_assunto

AutocompleteResponse {sugestoes: ["Aposentadoria", "Aposentadoria Especial", …]}
```

---

## Fluxo 4 — GET /hierarquia

```
Cliente
  │  GET /hierarquia

routes.hierarquia()

es_client.get_hierarquia()

  ├─ Query ES:
  │    size: 0  (sem hits, só agregações)
  │    aggs:
  │      ramos: terms{classes_nivel1, size:25}
  │        nivel2: terms{classes_nivel2, size:30}
  │          nivel3: terms{classes_nivel3, size:30}


Elasticsearch — aggregations aninhadas

builders.build_hierarquia_response(raw)
  │  ramos_buckets → HierarquiaNo(nome, caminho, total, filhos[])
  │  Para cada ramo → para cada nivel2 → para cada nivel3
  │  Recursão: filhos aninhados

HierarquiaResponse {ramos: [HierarquiaNo{nome, caminho, total, filhos[…]}]}
```

---

## Fluxo 5 — GET /grafo/filhos (drill-down)

```
Cliente
  │  GET /grafo/filhos?ancestor=Crimes+contra+o+Patrimônio&size=20

routes.drill_down(ancestor, size)

es_client.drill_down(ancestor, size)

  ├─ Query ES:
  │    size: 20
  │    _source.excludes: [texto_completo, glossario]
  │    query: term{classes_ancestors: "Crimes contra o Patrimônio"}


Elasticsearch — filtra por campo classes_ancestors (keyword array)

{ancestor, total, filhos:[_source dos hits]}
```

---

## Fluxo 6 — Inicialização do Sistema

```
docker-compose up

Container app: entrypoint.sh
  │  1. Loop: curl http://elasticsearch:9200/_cluster/health
  │            Aguarda status ≠ red (retry até 60s)
  │  2. Download:
  │      curl -L https://github.com/…/bulk_assuntos.ndjson
  │           -o /app/data/bulk_assuntos.ndjson
  │  3. exec uvicorn app.main:app

FastAPI lifespan (main.py)
  │  await es_client.setup_es()
  │    wait_for_es()  ← tenacity retry 10x
  │    Índice existe?
  │      NÃO → create_index() → bulk_index()
  │      SIM → count()
  │        count == 0 → bulk_index()
  │        count > 0  → skip (já indexado)

bulk_index()
  │  _iter_bulk_docs(): lê NDJSON, 2 linhas por doc
  │  async_streaming_bulk(chunk_size=500)
  │  Log: "5.184 documentos indexados"

API pronta — porta 8000
```

---

*Última atualização: 24/02/2026 · v1.0.0*