pinecall-runtime 0.1.6__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.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
"""Knowledge bases: the Markdown cutter, bases and files in Postgres, the search, the golden."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import LiteralString
|
|
8
|
+
|
|
9
|
+
from psycopg import sql
|
|
10
|
+
from psycopg.rows import DictRow
|
|
11
|
+
|
|
12
|
+
from pinecall.domain.agent import KnowledgeFile
|
|
13
|
+
from pinecall.domain.errors import WrongModel
|
|
14
|
+
from pinecall.domain.names import Env
|
|
15
|
+
from pinecall.domain.scope import Scope
|
|
16
|
+
from pinecall.postgres.pool import Pool, unbounded
|
|
17
|
+
from pinecall.retrieval._search import (
|
|
18
|
+
CANDIDATES_PER_BRANCH,
|
|
19
|
+
Evidence,
|
|
20
|
+
Figures,
|
|
21
|
+
SearchedTable,
|
|
22
|
+
evidence_of,
|
|
23
|
+
figures,
|
|
24
|
+
hybrid,
|
|
25
|
+
relative_to_the_best,
|
|
26
|
+
top_cosine,
|
|
27
|
+
)
|
|
28
|
+
from pinecall.retrieval._sections import section_texts, sections_chosen
|
|
29
|
+
from pinecall.retrieval.embed import Embedder, estimated_tokens, halfvec
|
|
30
|
+
|
|
31
|
+
# Small enough for eight chunks a turn, large enough for a whole tariff table.
|
|
32
|
+
CHUNK_TOKENS = 350
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# U+203A, the single right-pointing angle quote the stored heading paths are joined with.
|
|
36
|
+
HEADING_SEPARATOR = " \u203a "
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Both indexes read the heading path above the body, so a heading's words are searchable.
|
|
40
|
+
HEADING_JOINT = "\n\n"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Only levels 1 to 3 cut a file; a deeper heading is body.
|
|
44
|
+
A_HEADING = re.compile(r"^(#{1,3})\s+(.+?)\s*$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# A line inside a code fence is never a heading: `# install` in a shell block is a comment.
|
|
48
|
+
A_FENCE = re.compile(r"^\s*(```|~~~)")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
A_PARAGRAPH_BREAK = re.compile(r"\n\s*\n")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
A_SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Front matter is metadata: left in, a scraped site's became a chunk per page.
|
|
58
|
+
FRONT_MATTER = "---\n"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
A_CLOSING_RULE = re.compile(r"^---[ \t]*$", re.MULTILINE)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
TEXT_INDEX = "knowledge_chunks_text_bm25"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# '' sorts before any member id, so `holder DESC` puts the holder's own copy first: a read falls
|
|
68
|
+
# back to the org's copy, a write never does.
|
|
69
|
+
BASES = """
|
|
70
|
+
SELECT DISTINCT ON (base) base, chunks, model, extract(epoch FROM pushed_at)::float8 AS pushed_at
|
|
71
|
+
FROM knowledge_bases
|
|
72
|
+
WHERE org = %(org)s AND env = %(env)s AND holder IN (%(holder)s, '')
|
|
73
|
+
ORDER BY base, holder DESC
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
COPY = """
|
|
78
|
+
WITH copy AS (
|
|
79
|
+
SELECT holder FROM knowledge_bases
|
|
80
|
+
WHERE org = %(org)s AND env = %(env)s AND holder IN (%(holder)s, '') AND base = %(base)s
|
|
81
|
+
ORDER BY holder DESC LIMIT 1
|
|
82
|
+
)
|
|
83
|
+
SELECT file.path, length(file.text) AS chars, file.chunks, file.sha256, file.text,
|
|
84
|
+
extract(epoch FROM file.pushed_at)::float8 AS pushed_at
|
|
85
|
+
FROM copy LEFT JOIN knowledge_files AS file
|
|
86
|
+
ON file.org = %(org)s AND file.env = %(env)s AND file.holder = copy.holder
|
|
87
|
+
AND file.base = %(base)s AND file.path = coalesce(%(path)s::text, file.path)
|
|
88
|
+
ORDER BY file.path
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
OWN = """
|
|
93
|
+
SELECT base.model, file.path, file.sha256
|
|
94
|
+
FROM knowledge_bases AS base LEFT JOIN knowledge_files AS file USING (org, env, holder, base)
|
|
95
|
+
WHERE base.org = %(org)s AND base.env = %(env)s AND base.holder = %(holder)s
|
|
96
|
+
AND base.base = %(base)s
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# One statement, so a push lands whole or not at all. The subquery reads the rows as they were
|
|
101
|
+
# before the statement: the chunks it removes. `keep` null keeps every other file.
|
|
102
|
+
WRITE = """
|
|
103
|
+
WITH based AS (
|
|
104
|
+
INSERT INTO knowledge_bases (org, env, holder, base, model, dimensions, chunks, pushed_at)
|
|
105
|
+
VALUES (%(org)s, %(env)s, %(holder)s, %(base)s, %(model)s, %(dimensions)s, %(added)s,
|
|
106
|
+
to_timestamp(%(at)s))
|
|
107
|
+
ON CONFLICT (org, env, holder, base) DO UPDATE
|
|
108
|
+
SET model = excluded.model, dimensions = excluded.dimensions, pushed_at = excluded.pushed_at,
|
|
109
|
+
chunks = knowledge_bases.chunks + excluded.chunks - (
|
|
110
|
+
SELECT count(*) FROM knowledge_chunks AS gone
|
|
111
|
+
WHERE gone.org = %(org)s AND gone.env = %(env)s AND gone.holder = %(holder)s
|
|
112
|
+
AND gone.base = %(base)s
|
|
113
|
+
AND (gone.path <> ALL (coalesce(%(keep)s::text[], ARRAY[gone.path]))
|
|
114
|
+
OR gone.path = ANY (%(changed)s::text[]))
|
|
115
|
+
)
|
|
116
|
+
RETURNING base
|
|
117
|
+
), replaced AS (
|
|
118
|
+
DELETE FROM knowledge_chunks
|
|
119
|
+
WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
|
|
120
|
+
AND (path <> ALL (coalesce(%(keep)s::text[], ARRAY[path]))
|
|
121
|
+
OR path = ANY (%(changed)s::text[]))
|
|
122
|
+
), forgotten AS (
|
|
123
|
+
DELETE FROM knowledge_files
|
|
124
|
+
WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
|
|
125
|
+
AND path <> ALL (coalesce(%(keep)s::text[], ARRAY[path]))
|
|
126
|
+
), filed AS (
|
|
127
|
+
INSERT INTO knowledge_files (org, env, holder, base, path, text, chunks, sha256, pushed_at)
|
|
128
|
+
SELECT %(org)s, %(env)s, %(holder)s, %(base)s, file.path, file.text, file.chunks,
|
|
129
|
+
file.sha256, to_timestamp(%(at)s)
|
|
130
|
+
FROM unnest(%(changed)s::text[], %(texts)s::text[], %(counts)s::integer[],
|
|
131
|
+
%(hashes)s::text[]) AS file (path, text, chunks, sha256)
|
|
132
|
+
ON CONFLICT (org, env, holder, base, path) DO UPDATE
|
|
133
|
+
SET text = excluded.text, chunks = excluded.chunks, sha256 = excluded.sha256,
|
|
134
|
+
pushed_at = excluded.pushed_at
|
|
135
|
+
)
|
|
136
|
+
INSERT INTO knowledge_chunks (org, env, holder, base, path, heading, ordinal, text, embedding)
|
|
137
|
+
SELECT %(org)s, %(env)s, %(holder)s, %(base)s, chunk.path, chunk.heading, chunk.ordinal,
|
|
138
|
+
chunk.text, chunk.embedding::halfvec
|
|
139
|
+
FROM unnest(%(paths)s::text[], %(headings)s::text[], %(ordinals)s::integer[],
|
|
140
|
+
%(indexed)s::text[], %(vectors)s::text[]) AS chunk (path, heading, ordinal, text, embedding)
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Chunks cascade from the base.
|
|
145
|
+
DROP = """
|
|
146
|
+
DELETE FROM knowledge_bases
|
|
147
|
+
WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
|
|
148
|
+
RETURNING base
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# The count reads the chunks as they were before the statement: the ones it removes.
|
|
153
|
+
DROP_FILE = """
|
|
154
|
+
WITH gone AS (
|
|
155
|
+
DELETE FROM knowledge_files
|
|
156
|
+
WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
|
|
157
|
+
AND path = %(path)s
|
|
158
|
+
RETURNING path
|
|
159
|
+
), cut AS (
|
|
160
|
+
DELETE FROM knowledge_chunks
|
|
161
|
+
WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
|
|
162
|
+
AND path IN (SELECT path FROM gone)
|
|
163
|
+
), counted AS (
|
|
164
|
+
UPDATE knowledge_bases SET chunks = chunks - (
|
|
165
|
+
SELECT count(*) FROM knowledge_chunks
|
|
166
|
+
WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
|
|
167
|
+
AND path IN (SELECT path FROM gone)
|
|
168
|
+
)
|
|
169
|
+
WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
|
|
170
|
+
)
|
|
171
|
+
SELECT count(*) AS gone FROM gone
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# A second statement: a row cannot be updated and deleted in the same one.
|
|
176
|
+
DROP_AN_EMPTY_BASE = """
|
|
177
|
+
DELETE FROM knowledge_bases AS base
|
|
178
|
+
WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
|
|
179
|
+
AND NOT EXISTS (
|
|
180
|
+
SELECT 1 FROM knowledge_files AS file
|
|
181
|
+
WHERE file.org = base.org AND file.env = base.env AND file.holder = base.holder
|
|
182
|
+
AND file.base = base.base
|
|
183
|
+
)
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# Every holder's rows count: they are the org's, and they fill this world's disk.
|
|
188
|
+
KEPT = """
|
|
189
|
+
SELECT coalesce(sum(chunks), 0)::integer AS kept FROM knowledge_bases
|
|
190
|
+
WHERE org = %(org)s AND env = %(env)s
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
HELD = """
|
|
195
|
+
SELECT count(*)::integer AS held FROM knowledge_chunks
|
|
196
|
+
WHERE org = %(org)s AND env = %(env)s AND holder = %(holder)s AND base = %(base)s
|
|
197
|
+
AND path = coalesce(%(path)s::text, path)
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# Each base's copy is the holder's own, else the org's: the one its model is checked against.
|
|
202
|
+
COPIES = """
|
|
203
|
+
SELECT DISTINCT ON (base) base, holder, model FROM knowledge_bases
|
|
204
|
+
WHERE org = %(org)s AND env = %(env)s AND holder IN (%(holder)s, '') AND base = ANY (%(bases)s)
|
|
205
|
+
ORDER BY base, holder DESC
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
SCOPE: LiteralString = """
|
|
210
|
+
org = %(org)s AND env = %(env)s AND (base, holder) IN (
|
|
211
|
+
SELECT copy.base, copy.holder FROM unnest(%(bases)s::text[], %(holders)s::text[])
|
|
212
|
+
AS copy (base, holder)
|
|
213
|
+
)
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class Piece:
|
|
219
|
+
"""One chunk of a file before it is embedded: its heading path and its body."""
|
|
220
|
+
|
|
221
|
+
path: str
|
|
222
|
+
heading: str | None
|
|
223
|
+
ordinal: int
|
|
224
|
+
text: str
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass(frozen=True)
|
|
228
|
+
class Cut:
|
|
229
|
+
"""A file as a push writes it: its text, the hash of it, and its pieces."""
|
|
230
|
+
|
|
231
|
+
path: str
|
|
232
|
+
text: str
|
|
233
|
+
sha256: str
|
|
234
|
+
pieces: tuple[Piece, ...]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@dataclass(frozen=True)
|
|
238
|
+
class Base:
|
|
239
|
+
"""One base as listed: its size, the model that wrote its vectors, when it was pushed."""
|
|
240
|
+
|
|
241
|
+
base: str
|
|
242
|
+
chunks: int
|
|
243
|
+
model: str
|
|
244
|
+
pushed_at: float
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(frozen=True)
|
|
248
|
+
class File:
|
|
249
|
+
"""One file of a base; its text only when the file is read alone."""
|
|
250
|
+
|
|
251
|
+
path: str
|
|
252
|
+
chars: int
|
|
253
|
+
chunks: int
|
|
254
|
+
pushed_at: float
|
|
255
|
+
sha256: str
|
|
256
|
+
text: str | None = None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@dataclass(frozen=True)
|
|
260
|
+
class Push:
|
|
261
|
+
"""What a push writes into a base: the files as cut, and when it was pushed."""
|
|
262
|
+
|
|
263
|
+
base: str
|
|
264
|
+
files: tuple[Cut, ...]
|
|
265
|
+
at: float
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def chunks(self) -> int:
|
|
269
|
+
"""How many chunks the files were cut into."""
|
|
270
|
+
return sum(len(item.pieces) for item in self.files)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass(frozen=True)
|
|
274
|
+
class _Own:
|
|
275
|
+
model: str
|
|
276
|
+
hashes: dict[str, str]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass(frozen=True)
|
|
280
|
+
class SearchQuery:
|
|
281
|
+
"""What a search asks: the words, how many chunks at most, and each base with its floor."""
|
|
282
|
+
|
|
283
|
+
query: str
|
|
284
|
+
k: int
|
|
285
|
+
# A chunk's fused score must reach its own base's floor; 0.0 is none.
|
|
286
|
+
bases: Mapping[str, float]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass(frozen=True)
|
|
290
|
+
class Found:
|
|
291
|
+
"""One chunk found: where it is, its body, its score in this search, its cosine."""
|
|
292
|
+
|
|
293
|
+
id: str
|
|
294
|
+
base: str
|
|
295
|
+
path: str
|
|
296
|
+
heading: str | None
|
|
297
|
+
text: str
|
|
298
|
+
score: float
|
|
299
|
+
cosine: float
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@dataclass(frozen=True)
|
|
303
|
+
class Searched:
|
|
304
|
+
"""What a search found, and how strongly its best cosine says the answer is there."""
|
|
305
|
+
|
|
306
|
+
found: list[Found]
|
|
307
|
+
evidence: Evidence
|
|
308
|
+
model: str
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@dataclass(frozen=True)
|
|
312
|
+
class Question:
|
|
313
|
+
"""A golden question and the heading path that should answer it."""
|
|
314
|
+
|
|
315
|
+
asks: str
|
|
316
|
+
expects: str
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@dataclass(frozen=True)
|
|
320
|
+
class Answered:
|
|
321
|
+
"""A question and the chunks the search handed back, best first."""
|
|
322
|
+
|
|
323
|
+
question: Question
|
|
324
|
+
found: list[Found]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@dataclass(frozen=True)
|
|
328
|
+
class Score:
|
|
329
|
+
"""A golden run: how many questions, at which k, its two figures, and the questions missed."""
|
|
330
|
+
|
|
331
|
+
questions: int
|
|
332
|
+
k: int
|
|
333
|
+
figures: Figures
|
|
334
|
+
misses: list[Answered]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def chunks_of(path: str, text: str) -> list[Piece]:
|
|
338
|
+
"""The file cut at its headings, each piece under the cap, in file order."""
|
|
339
|
+
pieces: list[Piece] = []
|
|
340
|
+
for heading, body in _sections_of(_without_front_matter(text)):
|
|
341
|
+
for group in _under_the_cap(body, heading):
|
|
342
|
+
pieces.append(Piece(path, heading, len(pieces), group))
|
|
343
|
+
return pieces
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def cut(file: KnowledgeFile) -> Cut:
|
|
347
|
+
"""The file cut once, with the hash a later push compares against."""
|
|
348
|
+
digest = hashlib.sha256(file.text.encode("utf-8")).hexdigest()
|
|
349
|
+
return Cut(file.path, file.text, digest, tuple(chunks_of(file.path, file.text)))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def indexed_text(piece: Piece) -> str:
|
|
353
|
+
"""What both indexes read: the heading path above the body, or the body alone."""
|
|
354
|
+
return f"{piece.heading}{HEADING_JOINT}{piece.text}" if piece.heading else piece.text
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def body_of(indexed: str, heading: str | None) -> str:
|
|
358
|
+
"""The indexed text without its heading path: what the model reads."""
|
|
359
|
+
return indexed.removeprefix(f"{heading}{HEADING_JOINT}") if heading else indexed
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
async def bases(pool: Pool, scope: Scope) -> list[Base]:
|
|
363
|
+
"""The holder's bases, and the org's where the holder has pushed none of that name."""
|
|
364
|
+
async with pool.connection() as connection:
|
|
365
|
+
rows = await (await connection.execute(BASES, _where(scope))).fetchall()
|
|
366
|
+
return [
|
|
367
|
+
Base(base=row["base"], chunks=row["chunks"], model=row["model"], pushed_at=row["pushed_at"])
|
|
368
|
+
for row in rows
|
|
369
|
+
]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
async def files(pool: Pool, scope: Scope, base: str) -> list[File] | None:
|
|
373
|
+
"""Every file of the copy the holder reads, by path; None when there is no such base."""
|
|
374
|
+
rows = await _copy(pool, scope, base, None)
|
|
375
|
+
if not rows:
|
|
376
|
+
return None
|
|
377
|
+
return [_file(row, text=None) for row in rows if row["path"] is not None]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
async def file(pool: Pool, scope: Scope, base: str, path: str) -> File | None:
|
|
381
|
+
"""One file of the copy the holder reads, with its text."""
|
|
382
|
+
rows = await _copy(pool, scope, base, path)
|
|
383
|
+
found = [row for row in rows if row["path"] is not None]
|
|
384
|
+
return _file(found[0], text=found[0]["text"]) if found else None
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
async def put(pool: Pool, embedder: Embedder, scope: Scope, push: Push) -> None:
|
|
388
|
+
"""Replace the holder's base with the push; only the files whose text changed are embedded."""
|
|
389
|
+
await _written(pool, embedder, scope, push, keep=[item.path for item in push.files])
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
async def put_file(pool: Pool, embedder: Embedder, scope: Scope, push: Push) -> None:
|
|
393
|
+
"""Put the push's files beside the base's others, beginning the base when there is none."""
|
|
394
|
+
own = await _own(pool, scope, push.base)
|
|
395
|
+
if own is not None and own.model != embedder.embedding.model:
|
|
396
|
+
raise WrongModel(push.base, own.model, embedder.embedding.model)
|
|
397
|
+
await _written(pool, embedder, scope, push, keep=None)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
async def drop(pool: Pool, scope: Scope, base: str) -> bool:
|
|
401
|
+
"""Drop the holder's own copy of the base and its chunks; False when it has none."""
|
|
402
|
+
async with pool.connection() as connection:
|
|
403
|
+
dropped = await connection.execute(DROP, {**_where(scope), "base": base})
|
|
404
|
+
return await dropped.fetchone() is not None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
async def drop_file(pool: Pool, scope: Scope, base: str, path: str) -> bool:
|
|
408
|
+
"""Drop one file of the holder's own copy, and the base with its last file."""
|
|
409
|
+
named = {**_where(scope), "base": base, "path": path}
|
|
410
|
+
async with pool.connection() as connection, connection.transaction():
|
|
411
|
+
row = await (await connection.execute(DROP_FILE, named)).fetchone()
|
|
412
|
+
if row is None or row["gone"] == 0:
|
|
413
|
+
return False
|
|
414
|
+
await connection.execute(DROP_AN_EMPTY_BASE, named)
|
|
415
|
+
return True
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
async def kept(pool: Pool, org: str, env: Env) -> int:
|
|
419
|
+
"""How many chunks the org keeps in the world, every holder's."""
|
|
420
|
+
async with pool.connection() as connection:
|
|
421
|
+
row = await (await connection.execute(KEPT, {"org": org, "env": env})).fetchone()
|
|
422
|
+
return 0 if row is None else row["kept"]
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
async def chunks_kept(pool: Pool, scope: Scope, base: str, *, path: str | None = None) -> int:
|
|
426
|
+
"""How many chunks the holder's own copy of the base holds, or of one file of it."""
|
|
427
|
+
named = {**_where(scope), "base": base, "path": path}
|
|
428
|
+
async with pool.connection() as connection:
|
|
429
|
+
row = await (await connection.execute(HELD, named)).fetchone()
|
|
430
|
+
return 0 if row is None else row["held"]
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# Every base in one ranking: fused scores compare only within one, and searched one base at a
|
|
434
|
+
# time, an irrelevant base takes a turn's slots with a 1.0 of its own.
|
|
435
|
+
async def search(pool: Pool, embedder: Embedder, scope: Scope, query: SearchQuery) -> Searched:
|
|
436
|
+
"""The best chunks of every base asked, each over its own floor, at most k."""
|
|
437
|
+
model = embedder.embedding.model
|
|
438
|
+
async with pool.connection() as connection:
|
|
439
|
+
named = {**_where(scope), "bases": list(query.bases)}
|
|
440
|
+
copies = await (await connection.execute(COPIES, named)).fetchall()
|
|
441
|
+
for copy in copies:
|
|
442
|
+
if copy["model"] != model:
|
|
443
|
+
raise WrongModel(copy["base"], copy["model"], model)
|
|
444
|
+
if not copies:
|
|
445
|
+
return Searched(found=[], evidence="none", model=model)
|
|
446
|
+
[vector] = await embedder.embed([query.query])
|
|
447
|
+
table = SearchedTable(
|
|
448
|
+
name="knowledge_chunks",
|
|
449
|
+
text_index=TEXT_INDEX,
|
|
450
|
+
scope=sql.SQL(SCOPE),
|
|
451
|
+
params={
|
|
452
|
+
"org": scope.org,
|
|
453
|
+
"env": scope.env,
|
|
454
|
+
"bases": [copy["base"] for copy in copies],
|
|
455
|
+
"holders": [copy["holder"] for copy in copies],
|
|
456
|
+
},
|
|
457
|
+
columns=("base", "holder", "path", "heading", "ordinal", "text"),
|
|
458
|
+
)
|
|
459
|
+
room = CANDIDATES_PER_BRANCH * len(copies)
|
|
460
|
+
# independent: two reads, the sections only widening the chunks the search already found
|
|
461
|
+
async with pool.connection() as connection:
|
|
462
|
+
hits = await hybrid(connection, table, vector=vector, words=query.query, room=room)
|
|
463
|
+
floor = query.bases
|
|
464
|
+
floored = [hit for hit in relative_to_the_best(hits) if hit.fused >= floor[hit.row["base"]]]
|
|
465
|
+
chosen = sections_chosen([hit.row for hit in floored], query.k)
|
|
466
|
+
texts = await section_texts(connection, scope, [floored[at].row for at in chosen])
|
|
467
|
+
found = [
|
|
468
|
+
Found(
|
|
469
|
+
id=floored[at].id,
|
|
470
|
+
base=floored[at].row["base"],
|
|
471
|
+
path=floored[at].row["path"],
|
|
472
|
+
heading=floored[at].row["heading"],
|
|
473
|
+
text=HEADING_JOINT.join(
|
|
474
|
+
body_of(text, floored[at].row["heading"])
|
|
475
|
+
for text in texts.get(place, [floored[at].row["text"]])
|
|
476
|
+
),
|
|
477
|
+
score=floored[at].fused,
|
|
478
|
+
cosine=floored[at].cosine,
|
|
479
|
+
)
|
|
480
|
+
for place, at in enumerate(chosen)
|
|
481
|
+
]
|
|
482
|
+
best = top_cosine(hits)
|
|
483
|
+
return Searched(found=found, evidence=evidence_of(best), model=model)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def where(found: Found) -> str:
|
|
487
|
+
"""The chunk's file, then its heading path."""
|
|
488
|
+
return f"{found.path}{HEADING_SEPARATOR}{found.heading}" if found.heading else found.path
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# A file answers for every chunk of it and a heading for its section and what is under it; by
|
|
492
|
+
# prefix and separator, never substring: `tarifas.md` is not `tarifas-2024.md`.
|
|
493
|
+
def answers(found: str, expects: str) -> bool:
|
|
494
|
+
"""Whether a chunk's heading path is the one a golden expects, or under it."""
|
|
495
|
+
wanted = expects.strip()
|
|
496
|
+
return found == wanted or found.startswith(f"{wanted}{HEADING_SEPARATOR}")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def score_docs(answered: Sequence[Answered], k: int) -> Score:
|
|
500
|
+
"""Recall at k and nDCG at 10 over every question, and the ones whose chunk never came back."""
|
|
501
|
+
ranks = [_rank(item) for item in answered]
|
|
502
|
+
return Score(
|
|
503
|
+
questions=len(answered),
|
|
504
|
+
k=k,
|
|
505
|
+
figures=figures([[rank] for rank in ranks]),
|
|
506
|
+
misses=[item for item, rank in zip(answered, ranks, strict=True) if rank is None],
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
# An unclosed `---` is a horizontal rule, and the file is left as it is.
|
|
511
|
+
def _without_front_matter(text: str) -> str:
|
|
512
|
+
if not text.startswith(FRONT_MATTER):
|
|
513
|
+
return text
|
|
514
|
+
closed = A_CLOSING_RULE.search(text, len(FRONT_MATTER))
|
|
515
|
+
return text[closed.end() :].lstrip("\n") if closed else text
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# A level-n heading closes the open headings of level n and deeper.
|
|
519
|
+
def _sections_of(text: str) -> Iterator[tuple[str | None, str]]:
|
|
520
|
+
trail: list[tuple[int, str]] = []
|
|
521
|
+
body: list[str] = []
|
|
522
|
+
fence: str | None = None
|
|
523
|
+
for line in text.split("\n"):
|
|
524
|
+
fenced = A_FENCE.match(line)
|
|
525
|
+
if fenced is not None and fence in {None, fenced.group(1)}:
|
|
526
|
+
fence = None if fence else fenced.group(1)
|
|
527
|
+
heading = None if fence or fenced else A_HEADING.match(line)
|
|
528
|
+
if heading is None:
|
|
529
|
+
body.append(line)
|
|
530
|
+
continue
|
|
531
|
+
yield _path_of(trail), "\n".join(body)
|
|
532
|
+
body = []
|
|
533
|
+
level = len(heading.group(1))
|
|
534
|
+
trail = [*(kept for kept in trail if kept[0] < level), (level, heading.group(2))]
|
|
535
|
+
yield _path_of(trail), "\n".join(body)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _path_of(trail: Sequence[tuple[int, str]]) -> str | None:
|
|
539
|
+
return HEADING_SEPARATOR.join(title for _, title in trail) or None
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# The heading's tokens count against the cap: it is indexed with every piece under it.
|
|
543
|
+
def _under_the_cap(body: str, heading: str | None) -> list[str]:
|
|
544
|
+
room = CHUNK_TOKENS - estimated_tokens(heading or "")
|
|
545
|
+
parts = [part for paragraph in _paragraphs(body) for part in _within(paragraph, room)]
|
|
546
|
+
return _grouped(parts, HEADING_JOINT, room)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# A blank line inside a code fence is the code's own: the block stays one paragraph, so a class
|
|
550
|
+
# is never cut between two of its members and handed back as two halves.
|
|
551
|
+
def _paragraphs(body: str) -> list[str]:
|
|
552
|
+
paragraphs: list[str] = []
|
|
553
|
+
in_code = False
|
|
554
|
+
for paragraph in A_PARAGRAPH_BREAK.split(body):
|
|
555
|
+
if in_code:
|
|
556
|
+
paragraphs[-1] += HEADING_JOINT + paragraph
|
|
557
|
+
else:
|
|
558
|
+
paragraphs.append(paragraph)
|
|
559
|
+
if sum(1 for line in paragraph.splitlines() if A_FENCE.match(line)) % 2:
|
|
560
|
+
in_code = not in_code
|
|
561
|
+
return [paragraph.strip() for paragraph in paragraphs if paragraph.strip()]
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _within(paragraph: str, room: int) -> list[str]:
|
|
565
|
+
if estimated_tokens(paragraph) <= room or A_FENCE.match(paragraph):
|
|
566
|
+
return [paragraph]
|
|
567
|
+
return _grouped(A_SENTENCE_END.split(paragraph), " ", room)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# A part longer than the room is kept whole rather than cut mid-sentence.
|
|
571
|
+
def _grouped(parts: Sequence[str], joint: str, room: int) -> list[str]:
|
|
572
|
+
groups: list[str] = []
|
|
573
|
+
current: list[str] = []
|
|
574
|
+
for part in parts:
|
|
575
|
+
if current and estimated_tokens(joint.join([*current, part])) > room:
|
|
576
|
+
groups.append(joint.join(current))
|
|
577
|
+
current = []
|
|
578
|
+
current.append(part)
|
|
579
|
+
if current:
|
|
580
|
+
groups.append(joint.join(current))
|
|
581
|
+
return groups
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
async def _copy(pool: Pool, scope: Scope, base: str, path: str | None) -> list[DictRow]:
|
|
585
|
+
named = {**_where(scope), "base": base, "path": path}
|
|
586
|
+
async with pool.connection() as connection:
|
|
587
|
+
return await (await connection.execute(COPY, named)).fetchall()
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
async def _own(pool: Pool, scope: Scope, base: str) -> _Own | None:
|
|
591
|
+
async with pool.connection() as connection:
|
|
592
|
+
rows = await (await connection.execute(OWN, {**_where(scope), "base": base})).fetchall()
|
|
593
|
+
if not rows:
|
|
594
|
+
return None
|
|
595
|
+
hashes = {row["path"]: row["sha256"] for row in rows if row["path"] is not None}
|
|
596
|
+
return _Own(model=rows[0]["model"], hashes=hashes)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# A hash is only worth its vectors under the model that wrote them: under another, every file is
|
|
600
|
+
# embedded again. The vendor answers before the statement runs, so no connection waits on it.
|
|
601
|
+
async def _written(
|
|
602
|
+
pool: Pool, embedder: Embedder, scope: Scope, push: Push, *, keep: list[str] | None
|
|
603
|
+
) -> None:
|
|
604
|
+
own = await _own(pool, scope, push.base)
|
|
605
|
+
same_space = own is not None and own.model == embedder.embedding.model
|
|
606
|
+
known = own.hashes if own is not None and same_space else {}
|
|
607
|
+
changed = [item for item in push.files if known.get(item.path) != item.sha256]
|
|
608
|
+
documents = [[indexed_text(piece) for piece in item.pieces] for item in changed if item.pieces]
|
|
609
|
+
embedded = await embedder.embed_documents(documents) if documents else []
|
|
610
|
+
pieces = [piece for item in changed for piece in item.pieces]
|
|
611
|
+
written = [vector for document in embedded for vector in document]
|
|
612
|
+
named = {
|
|
613
|
+
**_where(scope),
|
|
614
|
+
"base": push.base,
|
|
615
|
+
"model": embedder.embedding.model,
|
|
616
|
+
"dimensions": embedder.embedding.dimensions,
|
|
617
|
+
"added": len(pieces),
|
|
618
|
+
"at": push.at,
|
|
619
|
+
"keep": keep,
|
|
620
|
+
"changed": [item.path for item in changed],
|
|
621
|
+
"texts": [item.text for item in changed],
|
|
622
|
+
"counts": [len(item.pieces) for item in changed],
|
|
623
|
+
"hashes": [item.sha256 for item in changed],
|
|
624
|
+
"paths": [piece.path for piece in pieces],
|
|
625
|
+
"headings": [piece.heading for piece in pieces],
|
|
626
|
+
"ordinals": [piece.ordinal for piece in pieces],
|
|
627
|
+
"indexed": [indexed_text(piece) for piece in pieces],
|
|
628
|
+
"vectors": [halfvec(vector) for vector in written],
|
|
629
|
+
}
|
|
630
|
+
# Unbounded: a base of thousands of chunks is that many rows into two indexes, in one statement.
|
|
631
|
+
async with unbounded(pool) as connection:
|
|
632
|
+
await connection.execute(WRITE, named)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _file(row: DictRow, *, text: str | None) -> File:
|
|
636
|
+
return File(
|
|
637
|
+
path=row["path"],
|
|
638
|
+
chars=row["chars"],
|
|
639
|
+
chunks=row["chunks"],
|
|
640
|
+
pushed_at=row["pushed_at"],
|
|
641
|
+
sha256=row["sha256"],
|
|
642
|
+
text=text,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _where(scope: Scope) -> dict[str, object]:
|
|
647
|
+
return {"org": scope.org, "env": scope.env, "holder": scope.holder}
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# Only the first match counts: a base that repeats itself answered once.
|
|
651
|
+
def _rank(item: Answered) -> int | None:
|
|
652
|
+
return next(
|
|
653
|
+
(
|
|
654
|
+
at
|
|
655
|
+
for at, chunk in enumerate(item.found, start=1)
|
|
656
|
+
if answers(where(chunk), item.question.expects)
|
|
657
|
+
),
|
|
658
|
+
None,
|
|
659
|
+
)
|