feat: harden search ranking and deduplication

This commit is contained in:
zhaoyilun
2026-07-10 09:39:18 +08:00
parent 1d6dd2f504
commit 6ca6c08b62
13 changed files with 368 additions and 56 deletions

View File

@@ -56,7 +56,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme
## Current loop ## Current loop
Current loop: `Business Roadmap / R2 - Contact and group search quality hardening`. Current loop: `Business Roadmap / R3 - Receive-file download mapping precheck`.
Plan file: `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`. Plan file: `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`.
@@ -124,4 +124,12 @@ R1 receive-message source reconciliation precheck is complete:
- Created `docs/source-discovery/2026-07-10-receive-message-reconciliation-precheck.md`. - Created `docs/source-discovery/2026-07-10-receive-message-reconciliation-precheck.md`.
- Decision: do not change default receive routing yet; empty/`auto` remains PacketReader/log-backed and `msglib_readonly` remains explicit. - Decision: do not change default receive routing yet; empty/`auto` remains PacketReader/log-backed and `msglib_readonly` remains explicit.
- Next node: R2 contact/group search quality hardening. - R1 next node was R2 contact/group search quality hardening.
R2 contact/group search quality hardening is complete:
- Search contacts now has deterministic exact-match-first ranking and case-insensitive de-duplication for log/JID candidates and optional MsgLib display enrichment.
- Search groups now has deterministic exact-match-first ranking and case-insensitive de-duplication for groupchat/conference candidates and optional MsgLib display enrichment.
- MCP output still preserves `source` and `raw_ref`; no send or download behavior was introduced.
- Next node: R3 receive-file download mapping precheck.

View File

@@ -96,8 +96,8 @@ The verification confirms:
- `isphere_receive_messages` returns the contract-facing `ok`, `conversation`, `messages`, `next_cursor`, and `audit` envelope, while preserving legacy message aliases such as `id`, `text`, and `timestamp`. - `isphere_receive_messages` returns the contract-facing `ok`, `conversation`, `messages`, `next_cursor`, and `audit` envelope, while preserving legacy message aliases such as `id`, `text`, and `timestamp`.
- A synthetic configured-source smoke check proves `ISPHERE_PACKET_LOG_FILE` can drive `isphere_receive_messages` end-to-end with one redacted fixture message. - A synthetic configured-source smoke check proves `ISPHERE_PACKET_LOG_FILE` can drive `isphere_receive_messages` end-to-end with one redacted fixture message.
- A synthetic configured-directory smoke check proves `ISPHERE_PACKET_LOG_DIR` can drive `isphere_receive_messages`, contact search, group search, and file-list extraction from a temporary directory fixture. - A synthetic configured-directory smoke check proves `ISPHERE_PACKET_LOG_DIR` can drive `isphere_receive_messages`, contact search, group search, and file-list extraction from a temporary directory fixture.
- `isphere_search_contacts` is callable and returns JID-derived contact candidates from the same configured synthetic message fixture. - `isphere_search_contacts` is callable and returns JID-derived contact candidates from the same configured synthetic message fixture; exact ID/display/account matches rank before prefix/substring matches and case-insensitive duplicates are collapsed.
- `isphere_search_groups` is callable and returns JID-derived group candidates from the configured synthetic groupchat fixture. - `isphere_search_groups` is callable and returns JID-derived group candidates from the configured synthetic groupchat fixture; exact ID/display-name matches rank before prefix/substring matches and case-insensitive duplicates are collapsed.
- `isphere_receive_files` is callable in list mode and returns one file metadata candidate from the configured synthetic file-transfer fixture. - `isphere_receive_files` is callable in list mode and returns one file metadata candidate from the configured synthetic file-transfer fixture.
- Verification uses synthetic/local helper checks and does not load raw N12-pre evidence. - Verification uses synthetic/local helper checks and does not load raw N12-pre evidence.
- The deterministic verification path clears MsgLib env variables, so it proves the default no-MsgLib behavior remains stable. - The deterministic verification path clears MsgLib env variables, so it proves the default no-MsgLib behavior remains stable.
@@ -246,8 +246,8 @@ These eight tools are currently allowed:
| `win_helper_scan_windows` | helper `scan_windows` | Read visible window metadata or likely iSphere candidates. | | `win_helper_scan_windows` | helper `scan_windows` | Read visible window metadata or likely iSphere candidates. |
| `win_helper_dump_uia` | helper `dump_uia` | Read a UI Automation tree for a specified window handle. | | `win_helper_dump_uia` | helper `dump_uia` | Read a UI Automation tree for a specified window handle. |
| `isphere_receive_messages` | log-backed `EncryptedPacketLogSource` | Read normalized messages from the configured PacketReader log source with a contract-shaped response envelope. The default server source is empty unless `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` is configured. | | `isphere_receive_messages` | log-backed `EncryptedPacketLogSource` | Read normalized messages from the configured PacketReader log source with a contract-shaped response envelope. The default server source is empty unless `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` is configured. |
| `isphere_search_contacts` | log-backed message JIDs | Search contact candidates extracted from sender/receiver bare JIDs in the configured message source. | | `isphere_search_contacts` | log-backed message JIDs plus optional MsgLib display enrichment | Search contact candidates extracted from sender/receiver bare JIDs in the configured message source, with deterministic exact-match-first ranking, case-insensitive de-duplication, and preserved `source`/`raw_ref`. |
| `isphere_search_groups` | log-backed groupchat JIDs | Search group candidates extracted from groupchat/conference/MUC bare JIDs in the configured message source. | | `isphere_search_groups` | log-backed groupchat JIDs plus optional MsgLib display enrichment | Search group candidates extracted from groupchat/conference/MUC bare JIDs in the configured message source, with deterministic exact-match-first ranking, case-insensitive de-duplication, and preserved `source`/`raw_ref`. |
| `isphere_receive_files` | log-backed file-transfer messages | List file metadata candidates extracted from file-transfer message bodies. Download/cache mapping is not implemented yet. | | `isphere_receive_files` | log-backed file-transfer messages | List file metadata candidates extracted from file-transfer message bodies. Download/cache mapping is not implemented yet. |
Allowed parameters: Allowed parameters:
@@ -274,7 +274,7 @@ The current runbook verifies eight tools:
- `isphere_search_groups` - `isphere_search_groups`
- `isphere_receive_files` - `isphere_receive_files`
The current log-backed business read tools have parser/source/tool registration and an empty default server source. Raw N12-pre evidence remains under ignored `runs/` paths and is not committed. The current command entry point can read an operator-local encrypted PacketReader log-line file via `ISPHERE_PACKET_LOG_FILE` or a directory of `.log`/`.txt` files via `ISPHERE_PACKET_LOG_DIR`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_messages` now exposes contract-facing fields for digital-employee consumption while preserving older aliases for compatibility, supports `conversation_id`, keyword `query`, and RFC3339 `since` filtering, and validates the remaining safe contract args without pretending unsupported connectors/pagination exist. Contact and group search also accept the safe local-readonly contract args and can optionally enrich display metadata from `MsgLibReadSidecar`; receive-message `sender_name` and `conversation.display_name` can use that same optional metadata when MsgLib env is configured. `isphere_receive_files` supports list mode with safe contract arg validation; download/cache mapping remains deferred. The current log-backed business read tools have parser/source/tool registration and an empty default server source. Raw N12-pre evidence remains under ignored `runs/` paths and is not committed. The current command entry point can read an operator-local encrypted PacketReader log-line file via `ISPHERE_PACKET_LOG_FILE` or a directory of `.log`/`.txt` files via `ISPHERE_PACKET_LOG_DIR`; this is still not a production ingestion claim because raw evidence remains local and uncommitted. `isphere_receive_messages` now exposes contract-facing fields for digital-employee consumption while preserving older aliases for compatibility, supports `conversation_id`, keyword `query`, and RFC3339 `since` filtering, and validates the remaining safe contract args without pretending unsupported connectors/pagination exist. Contact and group search also accept the safe local-readonly contract args, rank exact matches before prefix/substring matches, collapse case-insensitive duplicates across log-derived candidates and optional `MsgLibReadSidecar` display metadata, and preserve `source`/`raw_ref`; receive-message `sender_name` and `conversation.display_name` can use that same optional metadata when MsgLib env is configured. `isphere_receive_files` supports list mode with safe contract arg validation; download/cache mapping remains deferred.
Core business tools for contacts, groups, messages, and files are defined in `docs/mcp-core-tools-contract.md`. Send capabilities enter the roadmap through that contract after source discovery, connector selection, preview metadata, execution metadata, and audit records are in place. Core business tools for contacts, groups, messages, and files are defined in `docs/mcp-core-tools-contract.md`. Send capabilities enter the roadmap through that contract after source discovery, connector selection, preview metadata, execution metadata, and audit records are in place.

View File

@@ -18,8 +18,8 @@ Schema notes: `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md`
| MCP tool | Primary source | Fallback source | Evidence file | Decision status | Next implementation slice | | MCP tool | Primary source | Fallback source | Evidence file | Decision status | Next implementation slice |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `<message>` stanzas via `internal/isphere.EncryptedPacketLogSource`; configured by `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` | copied `MsgLib.db` message tables through x86 read-only sidecar `list_messages` selected explicitly by `source_preference="msglib_readonly"`; decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence`; `docs/source-discovery/2026-07-10-dotnet-wrapper-static-analysis.md`; `docs/source-discovery/2026-07-10-db-backed-receive-source-design.md`; `docs/source-discovery/2026-07-10-receive-message-reconciliation-precheck.md` | C20 supports and validates the current safe receive-message contract args; C32 wires optional MsgLib display metadata into receive-message `sender_name` and `conversation.display_name`; C33 proves this path through a real copied-DB MCP smoke; C35 maps MsgLib message tables; C36 adds metadata-only `message_sources`; C37 defines bounded `list_messages` design; C38 implements sidecar/Go-wrapper `list_messages` with sanitized copied-DB verification; C39 adds a Go adapter from `ListMessages` to the receive-message domain model; C40 adds tool-level explicit `msglib_readonly` source selection; C41 wires env-configured MsgLib receive source and optional copied-DB smoke; R1 precheck keeps `auto` PacketReader/log-backed until a future reconciliation helper is implemented | future receive reconciliation helper after R2/R3 | | `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `<message>` stanzas via `internal/isphere.EncryptedPacketLogSource`; configured by `ISPHERE_PACKET_LOG_FILE` or `ISPHERE_PACKET_LOG_DIR` | copied `MsgLib.db` message tables through x86 read-only sidecar `list_messages` selected explicitly by `source_preference="msglib_readonly"`; decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence`; `docs/source-discovery/2026-07-10-dotnet-wrapper-static-analysis.md`; `docs/source-discovery/2026-07-10-db-backed-receive-source-design.md`; `docs/source-discovery/2026-07-10-receive-message-reconciliation-precheck.md` | C20 supports and validates the current safe receive-message contract args; C32 wires optional MsgLib display metadata into receive-message `sender_name` and `conversation.display_name`; C33 proves this path through a real copied-DB MCP smoke; C35 maps MsgLib message tables; C36 adds metadata-only `message_sources`; C37 defines bounded `list_messages` design; C38 implements sidecar/Go-wrapper `list_messages` with sanitized copied-DB verification; C39 adds a Go adapter from `ListMessages` to the receive-message domain model; C40 adds tool-level explicit `msglib_readonly` source selection; C41 wires env-configured MsgLib receive source and optional copied-DB smoke; R1 precheck keeps `auto` PacketReader/log-backed until a future reconciliation helper is implemented | future receive reconciliation helper after R2/R3 |
| `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `TD_Roster`, `TD_CustomEffigy`, `tblRecent`, `tblChatLevel`, `tblPersonMsg`; decrypted roster/contact stanzas from `Smark.SendReceive`; UIA helper source if display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md` | C34 documents how to enable optional MsgLib contact display enrichment and verify sanitized MCP output; search enrichment remains available | R2 contact search quality hardening | | `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `TD_Roster`, `TD_CustomEffigy`, `tblRecent`, `tblChatLevel`, `tblPersonMsg`; decrypted roster/contact stanzas from `Smark.SendReceive`; UIA helper source if display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; `internal/isphere/contacts_test.go`; `internal/tools/isphere_contacts_test.go` | C34 documents optional MsgLib contact display enrichment; R2 adds deterministic exact-match-first ranking, case-insensitive de-duplication across log/MsgLib candidates, and preserves `source`/`raw_ref` | core contact search complete; optional richer fields later |
| `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `tblMsgGroupPersonMsg`, `TD_WorkGroupAuth`, `tblRecent`; UIA helper source if group display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C34 documents how to enable optional MsgLib group display enrichment and verify sanitized MCP output; search enrichment remains available | R2 group search quality hardening | | `isphere_search_groups` | decrypted `PacketReader.ProcessPacket` `type="groupchat"` stanzas and conference/MUC JIDs loaded from configured PacketReader file or directory source | validated copied `MsgLib.db` tables: `tblMsgGroupPersonMsg`, `TD_WorkGroupAuth`, `tblRecent`; UIA helper source if group display names are still missing | `docs/source-discovery/2026-07-10-msglib-readonly-schema-extraction.md`; `internal/isphere/groups_test.go`; `internal/tools/isphere_groups_test.go`; C9 ignored-log scan: PacketReader 86 groupchat/86 conference hits; Smark 24 groupchat/658 conference/631 muc hits | C34 documents optional MsgLib group display enrichment; R2 adds deterministic exact-match-first ranking, case-insensitive de-duplication across log/MsgLib candidates, and preserves `source`/`raw_ref` | core group search complete; optional member/owner hierarchy later |
| `isphere_receive_files` | decrypted `PacketReader.ProcessPacket` file-transfer message stanzas via `internal/isphere.ListFilesFromMessages` and `isphere_receive_files` list mode; configured PacketReader file or directory source; `zyl\importal\<hash>` cache entries remain unlinked | decrypted `Smark.SendReceive` and `SaveToDB` traces for file-reference reconciliation; future UIA/client connector for download | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#c12-file-transfer-evidence-precheck` | C22 verifies safe file-list contract args; no log-to-cache/download mapping yet | R3 receive-file download mapping precheck | | `isphere_receive_files` | decrypted `PacketReader.ProcessPacket` file-transfer message stanzas via `internal/isphere.ListFilesFromMessages` and `isphere_receive_files` list mode; configured PacketReader file or directory source; `zyl\importal\<hash>` cache entries remain unlinked | decrypted `Smark.SendReceive` and `SaveToDB` traces for file-reference reconciliation; future UIA/client connector for download | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#c12-file-transfer-evidence-precheck` | C22 verifies safe file-list contract args; no log-to-cache/download mapping yet | R3 receive-file download mapping precheck |
| `isphere_send_message` | none validated yet | existing bridge/API/local service preferred; UIA write connector only after internal-sandbox action evidence; network/protocol connector only after protocol discovery | `docs/source-discovery/2026-07-10-send-message-source-precheck.md` | C15 blocked: contract exists, but no committed bridge/API/local service, helper write op, UIA action chain, or protocol connector is validated | R5 send-message connector discovery | | `isphere_send_message` | none validated yet | existing bridge/API/local service preferred; UIA write connector only after internal-sandbox action evidence; network/protocol connector only after protocol discovery | `docs/source-discovery/2026-07-10-send-message-source-precheck.md` | C15 blocked: contract exists, but no committed bridge/API/local service, helper write op, UIA action chain, or protocol connector is validated | R5 send-message connector discovery |
| `isphere_send_file` | none validated yet | depends on send-message connector plus outbound file/upload evidence | `docs/source-discovery/2026-07-10-send-message-source-precheck.md` | blocked behind `isphere_send_message` connector selection and file upload/source policy | R7 send-file connector discovery | | `isphere_send_file` | none validated yet | depends on send-message connector plus outbound file/upload evidence | `docs/source-discovery/2026-07-10-send-message-source-precheck.md` | blocked behind `isphere_send_message` connector selection and file upload/source policy | R7 send-file connector discovery |
@@ -39,4 +39,4 @@ Start Stage C with a narrow log-backed `isphere_receive_messages` source abstrac
## Deferred source work ## Deferred source work
`MsgLib.db` has a validated copied read-only open path through the bundled 32-bit `System.Data.SQLite.dll` and password `123`. C27 adds `MsgLibReadSidecar` as the bounded x86 .NET reader boundary, C28 adds the Go `internal/msglib` process client, C29 adds bounded `display_entities` extraction, C30 wires it as optional contact/group MCP enrichment, C31 proves real copied-DB MCP enrichment with sanitized output, C32 reuses it for receive-message display fields, C33 proves that receive display path through a real copied-DB MCP smoke without printing entity values, C34 documents the operator setup/verification path, C35 maps MsgLib message tables to receive-message contract fields without reading row values, C36 adds metadata-only `message_sources` readiness, C37 defines the bounded DB-backed receive-source design, C38 implements sidecar/Go-wrapper `list_messages` with sanitized verification, C39 adds a Go adapter from MsgLib list results to the receive-message domain model, C40 adds explicit tool-level `msglib_readonly` source selection, C41 wires env-configured explicit MsgLib receive with optional sanitized smoke, and R1 documents source reconciliation keys. The active roadmap is `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`; next step is R2 contact/group search quality hardening. `MsgLib.db` has a validated copied read-only open path through the bundled 32-bit `System.Data.SQLite.dll` and password `123`. C27 adds `MsgLibReadSidecar` as the bounded x86 .NET reader boundary, C28 adds the Go `internal/msglib` process client, C29 adds bounded `display_entities` extraction, C30 wires it as optional contact/group MCP enrichment, C31 proves real copied-DB MCP enrichment with sanitized output, C32 reuses it for receive-message display fields, C33 proves that receive display path through a real copied-DB MCP smoke without printing entity values, C34 documents the operator setup/verification path, C35 maps MsgLib message tables to receive-message contract fields without reading row values, C36 adds metadata-only `message_sources` readiness, C37 defines the bounded DB-backed receive-source design, C38 implements sidecar/Go-wrapper `list_messages` with sanitized verification, C39 adds a Go adapter from MsgLib list results to the receive-message domain model, C40 adds explicit tool-level `msglib_readonly` source selection, C41 wires env-configured explicit MsgLib receive with optional sanitized smoke, R1 documents source reconciliation keys, and R2 hardens contact/group search ranking and de-duplication. The active roadmap is `docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md`; next step is R3 receive-file download mapping precheck.

View File

@@ -25,8 +25,8 @@
| Business goal | Current capability | Remaining blockers | Next node | | Business goal | Current capability | Remaining blockers | Next node |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Search contacts | Registered MCP tool; log/JID candidates; optional copied-DB MsgLib display enrichment. | Result quality hardening: de-duplication, ranking, stable IDs, optional richer fields. | R2 | | Search contacts | Registered MCP tool; deterministic exact-match ranking and case-insensitive de-duplication across log/JID candidates and optional copied-DB MsgLib display enrichment. | Optional richer fields such as department/title remain future enrichment, not a core search blocker. | complete for core search; optional enrichment later |
| Search groups | Registered MCP tool; groupchat/conference candidates; optional copied-DB MsgLib display enrichment. | Group member count, owner refs, nested/group hierarchy quality. | R2 | | Search groups | Registered MCP tool; deterministic exact-match ranking and case-insensitive de-duplication across groupchat/conference candidates and optional copied-DB MsgLib display enrichment. | Optional member count, owner refs, and nested/group hierarchy remain future enrichment, not a core search blocker. | complete for core search; optional enrichment later |
| Receive messages | PacketReader source works; copied `MsgLib.db` explicit receive works with `source_preference="msglib_readonly"`; R1 precheck says default should remain PacketReader until a future reconciliation helper is implemented. | Future reconciliation helper, source confidence scoring, pagination policy. | future receive helper after R2/R3 | | Receive messages | PacketReader source works; copied `MsgLib.db` explicit receive works with `source_preference="msglib_readonly"`; R1 precheck says default should remain PacketReader until a future reconciliation helper is implemented. | Future reconciliation helper, source confidence scoring, pagination policy. | future receive helper after R2/R3 |
| Send messages | Contract exists; no validated write connector. | Need connector discovery, preview-only path, idempotency and audit before production send. | R5 then R6 | | Send messages | Contract exists; no validated write connector. | Need connector discovery, preview-only path, idempotency and audit before production send. | R5 then R6 |
| Receive files | List mode exists from message-derived file metadata. | Need message-to-cache/download mapping and output-dir download behavior. | R3 then R4 | | Receive files | List mode exists from message-derived file metadata. | Need message-to-cache/download mapping and output-dir download behavior. | R3 then R4 |
@@ -150,7 +150,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-msglib-mcp-en
**Steps:** **Steps:**
- [ ] **Step 1: Add failing contact ranking test** - [x] **Step 1: Add failing contact ranking test**
Target behavior: Target behavior:
@@ -168,11 +168,11 @@ go test ./internal/isphere -run TestSearchContactsRanksExactAndDeduplicates -v
Expected before implementation: FAIL because ranking/dedup behavior is not implemented. Expected before implementation: FAIL because ranking/dedup behavior is not implemented.
- [ ] **Step 2: Implement contact ranking/dedup** - [x] **Step 2: Implement contact ranking/dedup**
Implement minimal deterministic ranking in `internal/isphere/contacts.go`. Implement minimal deterministic ranking in `internal/isphere/contacts.go`.
- [ ] **Step 3: Add failing group ranking test** - [x] **Step 3: Add failing group ranking test**
Run: Run:
@@ -182,7 +182,7 @@ go test ./internal/isphere -run TestSearchGroupsRanksExactAndDeduplicates -v
Expected before implementation: FAIL because ranking/dedup behavior is not implemented. Expected before implementation: FAIL because ranking/dedup behavior is not implemented.
- [ ] **Step 4: Implement group ranking/dedup** - [x] **Step 4: Implement group ranking/dedup**
Implement minimal deterministic ranking in `internal/isphere/groups.go`. Implement minimal deterministic ranking in `internal/isphere/groups.go`.
@@ -198,6 +198,14 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1
- Search contacts and search groups produce deterministic ranked results. - Search contacts and search groups produce deterministic ranked results.
- No send behavior is introduced. - No send behavior is introduced.
**R2 Result:**
- Added failing-then-passing tests for contact and group exact-match ranking and case-insensitive de-duplication in `internal/isphere`.
- Added failing-then-passing MCP tool tests proving optional MsgLib display entities and log-derived fallback entries merge into one de-duplicated result while preserving `source` and `raw_ref`.
- Implemented deterministic ranking: exact ID/display/account match first, prefix match second, substring match after that, then stable case-insensitive ID ordering.
- No send-message, send-file, file-download, hook, DB-write, or production side-effect behavior was introduced.
- Decision: next business node is R3 receive-file download mapping precheck.
--- ---
### R3: Receive-file download mapping precheck ### R3: Receive-file download mapping precheck

View File

@@ -30,24 +30,32 @@ func SearchContactsFromMessages(messages []Message, query SearchContactsQuery) S
addMessageContact(unique, message.ReceiverID) addMessageContact(unique, message.ReceiverID)
} }
queryText := strings.ToLower(strings.TrimSpace(query.Query)) queryText := normalizedSearchText(query.Query)
ids := make([]string, 0, len(unique)) contacts := make([]Contact, 0, len(unique))
for id := range unique { for _, contact := range unique {
if queryText == "" || strings.Contains(strings.ToLower(id), queryText) || strings.Contains(strings.ToLower(unique[id].DisplayName), queryText) { if contactMatchesQuery(contact, queryText) {
ids = append(ids, id) contacts = append(contacts, contact)
} }
} }
sort.Strings(ids) sort.Slice(contacts, func(i, j int) bool {
leftRank := contactSearchRank(contacts[i], queryText)
rightRank := contactSearchRank(contacts[j], queryText)
if leftRank != rightRank {
return leftRank < rightRank
}
leftID := strings.ToLower(contacts[i].ContactID)
rightID := strings.ToLower(contacts[j].ContactID)
if leftID != rightID {
return leftID < rightID
}
return contacts[i].ContactID < contacts[j].ContactID
})
limit := query.Limit limit := query.Limit
if limit <= 0 || limit > len(ids) { if limit <= 0 || limit > len(contacts) {
limit = len(ids) limit = len(contacts)
} }
contacts := make([]Contact, 0, limit) return SearchContactsResult{Contacts: contacts[:limit]}
for _, id := range ids[:limit] {
contacts = append(contacts, unique[id])
}
return SearchContactsResult{Contacts: contacts}
} }
func addMessageContact(unique map[string]Contact, id string) { func addMessageContact(unique map[string]Contact, id string) {
@@ -55,10 +63,11 @@ func addMessageContact(unique map[string]Contact, id string) {
if trimmed == "" { if trimmed == "" {
return return
} }
if _, exists := unique[trimmed]; exists { key := strings.ToLower(trimmed)
if _, exists := unique[key]; exists {
return return
} }
unique[trimmed] = Contact{ unique[key] = Contact{
ContactID: trimmed, ContactID: trimmed,
DisplayName: trimmed, DisplayName: trimmed,
Account: trimmed, Account: trimmed,
@@ -67,3 +76,38 @@ func addMessageContact(unique map[string]Contact, id string) {
RawRef: "message_jid", RawRef: "message_jid",
} }
} }
func normalizedSearchText(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func contactMatchesQuery(contact Contact, queryText string) bool {
if queryText == "" {
return true
}
return strings.Contains(strings.ToLower(contact.ContactID), queryText) ||
strings.Contains(strings.ToLower(contact.DisplayName), queryText) ||
strings.Contains(strings.ToLower(contact.Account), queryText)
}
func contactSearchRank(contact Contact, queryText string) int {
if queryText == "" {
return 0
}
values := []string{
strings.ToLower(contact.ContactID),
strings.ToLower(contact.DisplayName),
strings.ToLower(contact.Account),
}
for _, value := range values {
if value == queryText {
return 0
}
}
for _, value := range values {
if strings.HasPrefix(value, queryText) {
return 1
}
}
return 2
}

View File

@@ -20,3 +20,19 @@ func TestSearchContactsFromMessagesMatchesBareJIDs(t *testing.T) {
t.Fatalf("SearchContactsFromMessages() = %#v, want %#v", got, want) t.Fatalf("SearchContactsFromMessages() = %#v, want %#v", got, want)
} }
} }
func TestSearchContactsRanksExactAndDeduplicates(t *testing.T) {
messages := []Message{
{ID: "msg-1", SenderID: "zzz-target@imopenfire1-lanzhou", ReceiverID: "target@imopenfire1-lanzhou"},
{ID: "msg-2", SenderID: "TARGET@imopenfire1-lanzhou", ReceiverID: "other@imopenfire1-lanzhou"},
}
got := SearchContactsFromMessages(messages, SearchContactsQuery{Query: "target@imopenfire1-lanzhou", Limit: 10})
want := SearchContactsResult{Contacts: []Contact{
{ContactID: "target@imopenfire1-lanzhou", DisplayName: "target@imopenfire1-lanzhou", Account: "target@imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "message_jid"},
{ContactID: "zzz-target@imopenfire1-lanzhou", DisplayName: "zzz-target@imopenfire1-lanzhou", Account: "zzz-target@imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "message_jid"},
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("SearchContactsFromMessages() = %#v, want %#v", got, want)
}
}

View File

@@ -38,24 +38,32 @@ func SearchGroupsFromMessages(messages []Message, query SearchGroupsQuery) Searc
} }
} }
queryText := strings.ToLower(strings.TrimSpace(query.Query)) queryText := normalizedSearchText(query.Query)
ids := make([]string, 0, len(unique)) groups := make([]Group, 0, len(unique))
for id := range unique { for _, group := range unique {
if queryText == "" || strings.Contains(strings.ToLower(id), queryText) || strings.Contains(strings.ToLower(unique[id].DisplayName), queryText) { if groupMatchesQuery(group, queryText) {
ids = append(ids, id) groups = append(groups, group)
} }
} }
sort.Strings(ids) sort.Slice(groups, func(i, j int) bool {
leftRank := groupSearchRank(groups[i], queryText)
rightRank := groupSearchRank(groups[j], queryText)
if leftRank != rightRank {
return leftRank < rightRank
}
leftID := strings.ToLower(groups[i].GroupID)
rightID := strings.ToLower(groups[j].GroupID)
if leftID != rightID {
return leftID < rightID
}
return groups[i].GroupID < groups[j].GroupID
})
limit := query.Limit limit := query.Limit
if limit <= 0 || limit > len(ids) { if limit <= 0 || limit > len(groups) {
limit = len(ids) limit = len(groups)
} }
groups := make([]Group, 0, limit) return SearchGroupsResult{Groups: groups[:limit]}
for _, id := range ids[:limit] {
groups = append(groups, unique[id])
}
return SearchGroupsResult{Groups: groups}
} }
func addMessageGroup(unique map[string]Group, id string) { func addMessageGroup(unique map[string]Group, id string) {
@@ -63,10 +71,11 @@ func addMessageGroup(unique map[string]Group, id string) {
if trimmed == "" || !isLikelyGroupJID(trimmed) { if trimmed == "" || !isLikelyGroupJID(trimmed) {
return return
} }
if _, exists := unique[trimmed]; exists { key := strings.ToLower(trimmed)
if _, exists := unique[key]; exists {
return return
} }
unique[trimmed] = Group{ unique[key] = Group{
GroupID: trimmed, GroupID: trimmed,
DisplayName: trimmed, DisplayName: trimmed,
Source: "local_readonly", Source: "local_readonly",
@@ -79,3 +88,32 @@ func isLikelyGroupJID(id string) bool {
lower := strings.ToLower(strings.TrimSpace(id)) lower := strings.ToLower(strings.TrimSpace(id))
return strings.Contains(lower, "conference") || strings.Contains(lower, "muc") || strings.Contains(lower, "group") return strings.Contains(lower, "conference") || strings.Contains(lower, "muc") || strings.Contains(lower, "group")
} }
func groupMatchesQuery(group Group, queryText string) bool {
if queryText == "" {
return true
}
return strings.Contains(strings.ToLower(group.GroupID), queryText) ||
strings.Contains(strings.ToLower(group.DisplayName), queryText)
}
func groupSearchRank(group Group, queryText string) int {
if queryText == "" {
return 0
}
values := []string{
strings.ToLower(group.GroupID),
strings.ToLower(group.DisplayName),
}
for _, value := range values {
if value == queryText {
return 0
}
}
for _, value := range values {
if strings.HasPrefix(value, queryText) {
return 1
}
}
return 2
}

View File

@@ -20,3 +20,20 @@ func TestSearchGroupsFromMessagesMatchesGroupchatJIDs(t *testing.T) {
t.Fatalf("SearchGroupsFromMessages() = %#v, want %#v", got, want) t.Fatalf("SearchGroupsFromMessages() = %#v, want %#v", got, want)
} }
} }
func TestSearchGroupsRanksExactAndDeduplicates(t *testing.T) {
messages := []Message{
{ID: "msg-1", ConversationType: "groupchat", SenderID: "zzz-project@conference.imopenfire1-lanzhou", ReceiverID: "member@imopenfire1-lanzhou"},
{ID: "msg-2", ConversationType: "groupchat", SenderID: "project@conference.imopenfire1-lanzhou", ReceiverID: "member@imopenfire1-lanzhou"},
{ID: "msg-3", ConversationType: "groupchat", SenderID: "PROJECT@conference.imopenfire1-lanzhou", ReceiverID: "member@imopenfire1-lanzhou"},
}
got := SearchGroupsFromMessages(messages, SearchGroupsQuery{Query: "project@conference.imopenfire1-lanzhou", Limit: 10})
want := SearchGroupsResult{Groups: []Group{
{GroupID: "project@conference.imopenfire1-lanzhou", DisplayName: "project@conference.imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "groupchat_jid"},
{GroupID: "zzz-project@conference.imopenfire1-lanzhou", DisplayName: "zzz-project@conference.imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "groupchat_jid"},
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("SearchGroupsFromMessages() = %#v, want %#v", got, want)
}
}

View File

@@ -2,6 +2,7 @@ package tools
import ( import (
"context" "context"
"sort"
"strings" "strings"
"isphere-ai-bridge/internal/isphere" "isphere-ai-bridge/internal/isphere"
@@ -73,7 +74,7 @@ func groupsFromDisplayEntities(entities []msglib.DisplayEntity) []isphere.Group
return groups return groups
} }
func mergeContacts(primary []isphere.Contact, fallback []isphere.Contact, limit int) isphere.SearchContactsResult { func mergeContacts(primary []isphere.Contact, fallback []isphere.Contact, query string, limit int) isphere.SearchContactsResult {
seen := map[string]bool{} seen := map[string]bool{}
contacts := make([]isphere.Contact, 0, len(primary)+len(fallback)) contacts := make([]isphere.Contact, 0, len(primary)+len(fallback))
for _, contact := range append(primary, fallback...) { for _, contact := range append(primary, fallback...) {
@@ -83,14 +84,15 @@ func mergeContacts(primary []isphere.Contact, fallback []isphere.Contact, limit
} }
seen[strings.ToLower(id)] = true seen[strings.ToLower(id)] = true
contacts = append(contacts, contact) contacts = append(contacts, contact)
if limit > 0 && len(contacts) >= limit { }
break sortContactsForQuery(contacts, query)
} if limit > 0 && len(contacts) > limit {
contacts = contacts[:limit]
} }
return isphere.SearchContactsResult{Contacts: contacts} return isphere.SearchContactsResult{Contacts: contacts}
} }
func mergeGroups(primary []isphere.Group, fallback []isphere.Group, limit int) isphere.SearchGroupsResult { func mergeGroups(primary []isphere.Group, fallback []isphere.Group, query string, limit int) isphere.SearchGroupsResult {
seen := map[string]bool{} seen := map[string]bool{}
groups := make([]isphere.Group, 0, len(primary)+len(fallback)) groups := make([]isphere.Group, 0, len(primary)+len(fallback))
for _, group := range append(primary, fallback...) { for _, group := range append(primary, fallback...) {
@@ -100,9 +102,87 @@ func mergeGroups(primary []isphere.Group, fallback []isphere.Group, limit int) i
} }
seen[strings.ToLower(id)] = true seen[strings.ToLower(id)] = true
groups = append(groups, group) groups = append(groups, group)
if limit > 0 && len(groups) >= limit { }
break sortGroupsForQuery(groups, query)
} if limit > 0 && len(groups) > limit {
groups = groups[:limit]
} }
return isphere.SearchGroupsResult{Groups: groups} return isphere.SearchGroupsResult{Groups: groups}
} }
func sortContactsForQuery(contacts []isphere.Contact, query string) {
queryText := strings.ToLower(strings.TrimSpace(query))
sort.Slice(contacts, func(i, j int) bool {
leftRank := contactRankForQuery(contacts[i], queryText)
rightRank := contactRankForQuery(contacts[j], queryText)
if leftRank != rightRank {
return leftRank < rightRank
}
leftID := strings.ToLower(contacts[i].ContactID)
rightID := strings.ToLower(contacts[j].ContactID)
if leftID != rightID {
return leftID < rightID
}
return contacts[i].ContactID < contacts[j].ContactID
})
}
func contactRankForQuery(contact isphere.Contact, queryText string) int {
if queryText == "" {
return 0
}
values := []string{
strings.ToLower(contact.ContactID),
strings.ToLower(contact.DisplayName),
strings.ToLower(contact.Account),
}
for _, value := range values {
if value == queryText {
return 0
}
}
for _, value := range values {
if strings.HasPrefix(value, queryText) {
return 1
}
}
return 2
}
func sortGroupsForQuery(groups []isphere.Group, query string) {
queryText := strings.ToLower(strings.TrimSpace(query))
sort.Slice(groups, func(i, j int) bool {
leftRank := groupRankForQuery(groups[i], queryText)
rightRank := groupRankForQuery(groups[j], queryText)
if leftRank != rightRank {
return leftRank < rightRank
}
leftID := strings.ToLower(groups[i].GroupID)
rightID := strings.ToLower(groups[j].GroupID)
if leftID != rightID {
return leftID < rightID
}
return groups[i].GroupID < groups[j].GroupID
})
}
func groupRankForQuery(group isphere.Group, queryText string) int {
if queryText == "" {
return 0
}
values := []string{
strings.ToLower(group.GroupID),
strings.ToLower(group.DisplayName),
}
for _, value := range values {
if value == queryText {
return 0
}
}
for _, value := range values {
if strings.HasPrefix(value, queryText) {
return 1
}
}
return 2
}

View File

@@ -52,7 +52,7 @@ func RegisterISphereContactToolsWithDisplayEntities(server *mcp.Server, source R
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
contacts = mergeContacts(contactsFromDisplayEntities(entities), logContacts.Contacts, input.Limit) contacts = mergeContacts(contactsFromDisplayEntities(entities), logContacts.Contacts, input.Query, input.Limit)
} }
return nil, searchContactsResultToMap(contacts, started, time.Now().UTC()), nil return nil, searchContactsResultToMap(contacts, started, time.Now().UTC()), nil
}) })

View File

@@ -131,6 +131,56 @@ func TestISphereSearchContactsToolUsesInjectedDisplayEntities(t *testing.T) {
} }
} }
func TestISphereSearchContactsToolRanksExactAndDeduplicatesDisplayEntities(t *testing.T) {
fakeMessages := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-1",
SenderID: "target@imopenfire1-lanzhou",
ReceiverID: "other@imopenfire1-lanzhou",
}}},
}
fakeDisplay := &fakeDisplayEntitySource{entities: []msglib.DisplayEntity{
{EntityType: msglib.EntityTypeContacts, SourceTable: "TD_CustomEffigy", JID: "zzz-target@imopenfire1-lanzhou", DisplayName: "ZZZ Target", Confidence: 0.8},
{EntityType: msglib.EntityTypeContacts, SourceTable: "TD_CustomEffigy", JID: "target@imopenfire1-lanzhou", DisplayName: "Target", Confidence: 0.9},
}}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereContactToolsWithDisplayEntities(server, fakeMessages, fakeDisplay)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchContacts,
Arguments: map[string]any{"query": "target@imopenfire1-lanzhou", "limit": 10},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSearchContacts, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
Contacts []struct {
ContactID string `json:"contact_id"`
Source string `json:"source"`
RawRef string `json:"raw_ref"`
} `json:"contacts"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Contacts) != 2 {
t.Fatalf("contacts = %+v, want exact and substring only", decoded.Contacts)
}
if decoded.Contacts[0].ContactID != "target@imopenfire1-lanzhou" || decoded.Contacts[0].Source != "msglib_readonly" || decoded.Contacts[0].RawRef != "msglib:TD_CustomEffigy" {
t.Fatalf("first contact should be exact MsgLib match with source metadata, got %+v", decoded.Contacts[0])
}
if decoded.Contacts[1].ContactID != "zzz-target@imopenfire1-lanzhou" {
t.Fatalf("second contact should be substring match, got %+v", decoded.Contacts[1])
}
}
func TestISphereSearchContactsToolValidatesContractArgs(t *testing.T) { func TestISphereSearchContactsToolValidatesContractArgs(t *testing.T) {
fake := &fakeReceiveMessagesSource{ fake := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{ result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{

View File

@@ -52,7 +52,7 @@ func RegisterISphereGroupToolsWithDisplayEntities(server *mcp.Server, source Rec
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
groups = mergeGroups(groupsFromDisplayEntities(entities), logGroups.Groups, input.Limit) groups = mergeGroups(groupsFromDisplayEntities(entities), logGroups.Groups, input.Query, input.Limit)
} }
return nil, searchGroupsResultToMap(groups, started, time.Now().UTC()), nil return nil, searchGroupsResultToMap(groups, started, time.Now().UTC()), nil
}) })

View File

@@ -135,6 +135,57 @@ func TestISphereSearchGroupsToolUsesInjectedDisplayEntities(t *testing.T) {
} }
} }
func TestISphereSearchGroupsToolRanksExactAndDeduplicatesDisplayEntities(t *testing.T) {
fakeMessages := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{
ID: "msg-1",
ConversationType: "groupchat",
SenderID: "target@conference.imopenfire1-lanzhou",
ReceiverID: "member@imopenfire1-lanzhou",
}}},
}
fakeDisplay := &fakeDisplayEntitySource{entities: []msglib.DisplayEntity{
{EntityType: msglib.EntityTypeGroups, SourceTable: "TD_WorkGroupAuth", JID: "zzz-target@conference.imopenfire1-lanzhou", DisplayName: "ZZZ Target Group", Confidence: 0.8},
{EntityType: msglib.EntityTypeGroups, SourceTable: "TD_WorkGroupAuth", JID: "target@conference.imopenfire1-lanzhou", DisplayName: "Target Group", Confidence: 0.9},
}}
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
RegisterISphereGroupToolsWithDisplayEntities(server, fakeMessages, fakeDisplay)
})
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: ToolNameSearchGroups,
Arguments: map[string]any{"query": "target@conference.imopenfire1-lanzhou", "limit": 10},
})
if err != nil {
t.Fatalf("call %s: %v", ToolNameSearchGroups, err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
Groups []struct {
GroupID string `json:"group_id"`
Source string `json:"source"`
RawRef string `json:"raw_ref"`
} `json:"groups"`
}
payload, _ := json.Marshal(callResult.StructuredContent)
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Groups) != 2 {
t.Fatalf("groups = %+v, want exact and substring only", decoded.Groups)
}
if decoded.Groups[0].GroupID != "target@conference.imopenfire1-lanzhou" || decoded.Groups[0].Source != "msglib_readonly" || decoded.Groups[0].RawRef != "msglib:TD_WorkGroupAuth" {
t.Fatalf("first group should be exact MsgLib match with source metadata, got %+v", decoded.Groups[0])
}
if decoded.Groups[1].GroupID != "zzz-target@conference.imopenfire1-lanzhou" {
t.Fatalf("second group should be substring match, got %+v", decoded.Groups[1])
}
}
func TestISphereSearchGroupsToolValidatesContractArgs(t *testing.T) { func TestISphereSearchGroupsToolValidatesContractArgs(t *testing.T) {
fake := &fakeReceiveMessagesSource{ fake := &fakeReceiveMessagesSource{
result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{ result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{