diff --git a/docs/current-status-card.md b/docs/current-status-card.md index 9c52a86..7b61cda 100644 --- a/docs/current-status-card.md +++ b/docs/current-status-card.md @@ -56,7 +56,7 @@ N13/N14/N15 are pre-business validation results. They can help identify UI eleme ## 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`. @@ -124,4 +124,12 @@ R1 receive-message source reconciliation precheck is complete: - 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. -- 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. diff --git a/docs/go-mcp-runbook.md b/docs/go-mcp-runbook.md index 9f1b09c..271c976 100644 --- a/docs/go-mcp-runbook.md +++ b/docs/go-mcp-runbook.md @@ -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`. - 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. -- `isphere_search_contacts` is callable and returns JID-derived contact candidates from the same configured synthetic message fixture. -- `isphere_search_groups` is callable and returns JID-derived group candidates from the configured synthetic groupchat 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; 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. - 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. @@ -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_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_search_contacts` | log-backed message JIDs | Search contact candidates extracted from sender/receiver bare JIDs in the configured message source. | -| `isphere_search_groups` | log-backed groupchat JIDs | Search group candidates extracted from groupchat/conference/MUC 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 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. | Allowed parameters: @@ -274,7 +274,7 @@ The current runbook verifies eight tools: - `isphere_search_groups` - `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. diff --git a/docs/source-discovery/capability-source-matrix.md b/docs/source-discovery/capability-source-matrix.md index c9e05cd..23b14b9 100644 --- a/docs/source-discovery/capability-source-matrix.md +++ b/docs/source-discovery/capability-source-matrix.md @@ -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 | | --- | --- | --- | --- | --- | --- | | `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `` 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_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_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`; `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\` 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_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 -`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. diff --git a/docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md b/docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md index 0186230..b22f62c 100644 --- a/docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md +++ b/docs/superpowers/plans/2026-07-10-core-business-capabilities-roadmap.md @@ -25,8 +25,8 @@ | 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 groups | Registered MCP tool; groupchat/conference candidates; optional copied-DB MsgLib display enrichment. | Group member count, owner refs, nested/group hierarchy quality. | 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; 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 | | 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 | @@ -150,7 +150,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-msglib-mcp-en **Steps:** -- [ ] **Step 1: Add failing contact ranking test** +- [x] **Step 1: Add failing contact ranking test** Target behavior: @@ -168,11 +168,11 @@ go test ./internal/isphere -run TestSearchContactsRanksExactAndDeduplicates -v 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`. -- [ ] **Step 3: Add failing group ranking test** +- [x] **Step 3: Add failing group ranking test** Run: @@ -182,7 +182,7 @@ go test ./internal/isphere -run TestSearchGroupsRanksExactAndDeduplicates -v 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`. @@ -198,6 +198,14 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-go-mcp.ps1 - Search contacts and search groups produce deterministic ranked results. - 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 diff --git a/internal/isphere/contacts.go b/internal/isphere/contacts.go index 617cb64..cf543f3 100644 --- a/internal/isphere/contacts.go +++ b/internal/isphere/contacts.go @@ -30,24 +30,32 @@ func SearchContactsFromMessages(messages []Message, query SearchContactsQuery) S addMessageContact(unique, message.ReceiverID) } - queryText := strings.ToLower(strings.TrimSpace(query.Query)) - ids := make([]string, 0, len(unique)) - for id := range unique { - if queryText == "" || strings.Contains(strings.ToLower(id), queryText) || strings.Contains(strings.ToLower(unique[id].DisplayName), queryText) { - ids = append(ids, id) + queryText := normalizedSearchText(query.Query) + contacts := make([]Contact, 0, len(unique)) + for _, contact := range unique { + if contactMatchesQuery(contact, queryText) { + 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 - if limit <= 0 || limit > len(ids) { - limit = len(ids) + if limit <= 0 || limit > len(contacts) { + limit = len(contacts) } - contacts := make([]Contact, 0, limit) - for _, id := range ids[:limit] { - contacts = append(contacts, unique[id]) - } - return SearchContactsResult{Contacts: contacts} + return SearchContactsResult{Contacts: contacts[:limit]} } func addMessageContact(unique map[string]Contact, id string) { @@ -55,10 +63,11 @@ func addMessageContact(unique map[string]Contact, id string) { if trimmed == "" { return } - if _, exists := unique[trimmed]; exists { + key := strings.ToLower(trimmed) + if _, exists := unique[key]; exists { return } - unique[trimmed] = Contact{ + unique[key] = Contact{ ContactID: trimmed, DisplayName: trimmed, Account: trimmed, @@ -67,3 +76,38 @@ func addMessageContact(unique map[string]Contact, id string) { 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 +} diff --git a/internal/isphere/contacts_test.go b/internal/isphere/contacts_test.go index 4deabf6..09d36c8 100644 --- a/internal/isphere/contacts_test.go +++ b/internal/isphere/contacts_test.go @@ -20,3 +20,19 @@ func TestSearchContactsFromMessagesMatchesBareJIDs(t *testing.T) { 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) + } +} diff --git a/internal/isphere/groups.go b/internal/isphere/groups.go index cf025fc..3f1be0b 100644 --- a/internal/isphere/groups.go +++ b/internal/isphere/groups.go @@ -38,24 +38,32 @@ func SearchGroupsFromMessages(messages []Message, query SearchGroupsQuery) Searc } } - queryText := strings.ToLower(strings.TrimSpace(query.Query)) - ids := make([]string, 0, len(unique)) - for id := range unique { - if queryText == "" || strings.Contains(strings.ToLower(id), queryText) || strings.Contains(strings.ToLower(unique[id].DisplayName), queryText) { - ids = append(ids, id) + queryText := normalizedSearchText(query.Query) + groups := make([]Group, 0, len(unique)) + for _, group := range unique { + if groupMatchesQuery(group, queryText) { + 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 - if limit <= 0 || limit > len(ids) { - limit = len(ids) + if limit <= 0 || limit > len(groups) { + limit = len(groups) } - groups := make([]Group, 0, limit) - for _, id := range ids[:limit] { - groups = append(groups, unique[id]) - } - return SearchGroupsResult{Groups: groups} + return SearchGroupsResult{Groups: groups[:limit]} } func addMessageGroup(unique map[string]Group, id string) { @@ -63,10 +71,11 @@ func addMessageGroup(unique map[string]Group, id string) { if trimmed == "" || !isLikelyGroupJID(trimmed) { return } - if _, exists := unique[trimmed]; exists { + key := strings.ToLower(trimmed) + if _, exists := unique[key]; exists { return } - unique[trimmed] = Group{ + unique[key] = Group{ GroupID: trimmed, DisplayName: trimmed, Source: "local_readonly", @@ -79,3 +88,32 @@ func isLikelyGroupJID(id string) bool { lower := strings.ToLower(strings.TrimSpace(id)) 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 +} diff --git a/internal/isphere/groups_test.go b/internal/isphere/groups_test.go index d0c232c..ae32396 100644 --- a/internal/isphere/groups_test.go +++ b/internal/isphere/groups_test.go @@ -20,3 +20,20 @@ func TestSearchGroupsFromMessagesMatchesGroupchatJIDs(t *testing.T) { 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) + } +} diff --git a/internal/tools/display_entities.go b/internal/tools/display_entities.go index 60c0968..2bae192 100644 --- a/internal/tools/display_entities.go +++ b/internal/tools/display_entities.go @@ -2,6 +2,7 @@ package tools import ( "context" + "sort" "strings" "isphere-ai-bridge/internal/isphere" @@ -73,7 +74,7 @@ func groupsFromDisplayEntities(entities []msglib.DisplayEntity) []isphere.Group 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{} contacts := make([]isphere.Contact, 0, len(primary)+len(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 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} } -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{} groups := make([]isphere.Group, 0, len(primary)+len(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 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} } + +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 +} diff --git a/internal/tools/isphere_contacts.go b/internal/tools/isphere_contacts.go index 06327fb..4676861 100644 --- a/internal/tools/isphere_contacts.go +++ b/internal/tools/isphere_contacts.go @@ -52,7 +52,7 @@ func RegisterISphereContactToolsWithDisplayEntities(server *mcp.Server, source R if err != nil { 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 }) diff --git a/internal/tools/isphere_contacts_test.go b/internal/tools/isphere_contacts_test.go index e6ab2ba..5845eb9 100644 --- a/internal/tools/isphere_contacts_test.go +++ b/internal/tools/isphere_contacts_test.go @@ -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) { fake := &fakeReceiveMessagesSource{ result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{ diff --git a/internal/tools/isphere_groups.go b/internal/tools/isphere_groups.go index c86ac65..1d34bea 100644 --- a/internal/tools/isphere_groups.go +++ b/internal/tools/isphere_groups.go @@ -52,7 +52,7 @@ func RegisterISphereGroupToolsWithDisplayEntities(server *mcp.Server, source Rec if err != nil { 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 }) diff --git a/internal/tools/isphere_groups_test.go b/internal/tools/isphere_groups_test.go index 2f99175..7dc97d7 100644 --- a/internal/tools/isphere_groups_test.go +++ b/internal/tools/isphere_groups_test.go @@ -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) { fake := &fakeReceiveMessagesSource{ result: isphere.ReceiveMessagesResult{Messages: []isphere.Message{{