Execute a Cypher query against Enterpret's Knowledge Graph.
Translates your Cypher to SQL and returns structured results. Enterpret's KG unifies 100+ sources (Zendesk, Gong, Slack, AppStore, etc.) into a single graph. Source identity: nli.source. Source-specific metadata fields live on whichever entity the field belongs to — qualify each one with the alias matching its entity group as returned by search_graph_fields (nli.* for NLI/FeedbackRecord, the joined account/user alias for Account/User fields).
BEFORE writing Cypher:
- If your query involves source-specific fields (ratings, tags, account names, plan types,
custom metadata), you MUST call search_graph_fields first to discover exact field names.
- Call get_query_examples to see proven patterns for similar queries.
You MUST follow the Enterpret-specific Cypher rules below. Standard Neo4j Cypher patterns will often fail or return incorrect results due to the KG's structure (relationship fan-out creates duplicate rows).
Only apply LIMIT when the user explicitly requests a subset or examples.
No LIMIT — user wants a true total or complete breakdown:
- "how many" / "total" / "count" → COUNT query, no LIMIT
- "all feedback" / "every" / "locate all" → exhaustive retrieval, no LIMIT
- "breakdown by X" / GROUP BY → all groups needed, no LIMIT
Good: RETURN COUNT(DISTINCT nli.record_id) AS total Good: RETURN language, COUNT(DISTINCT nli.record_id) AS total ORDER BY total DESC Bad: RETURN COUNT(DISTINCT nli.record_id) AS total LIMIT 100 -- caps the count! Bad: RETURN ... LIMIT 50 -- when user asked for "all", hides results
Use LIMIT — user wants a ranked subset or examples:
- "top 5" / "biggest" / "most common" → LIMIT N (default 50)
- "show me some examples" / "sample feedback" → platform-aware limit:
Gong/Zoom calls ~3-5, Zendesk tickets ~10, Surveys ~20-50
Always use COUNT(DISTINCT entity.record_id). Never COUNT(*) or COUNT(entity). Graph traversals create duplicate rows: 1 NLI → many FI → many CFT.
Good: COUNT(DISTINCT nli.record_id) AS total Bad: COUNT(*) AS total -- inflated by fan-out duplicates
Feedback count → COUNT(DISTINCT nli.record_id) User/customer count → COUNT(DISTINCT <user_alias>.record_id) via the user-join path your org's schema exposes (PROVIDED_BY_USER → User or HAS_USER → DerivedUser; see get_graph_schema and search_graph_fields).
Always use DISTINCT when returning NLI rows for samples or examples. Without it, the same record_id may appear multiple times due to fan-out.
Good: RETURN DISTINCT nli.record_id AS record_id, fi.content AS summary Bad: RETURN nli.record_id, fi.content -- duplicates likely
Every query using Theme, Subtheme, L1, L2, or L3 MUST include: entity.type != 'MISC' This applies to all query types — counts, samples, filtered, exploratory.
Good: WHERE t.category_enum CONTAINS 'COMPLAINT' AND t.type != 'MISC' Bad: WHERE t.category_enum CONTAINS 'COMPLAINT' -- includes junk MISC entries For OPTIONAL MATCH: WHERE (t IS NULL OR t.type != 'MISC') Only omit if user explicitly says "include miscellaneous".
Relative dates — always use INTERVAL, never hardcode: Good: WHERE nli.record_timestamp >= now()
Good: WHERE opp.close_date >= now()
Bad: WHERE opp.close_date >= toDateTime('2024-06-01') -- breaks over time
Literal dates: toDateTime('YYYY-MM-DD HH:MM:SS') Month or month range without year: use the most recent fully completed occurrence relative to the current date. If the month/range is upcoming or still in progress this year, use the previous year. For "last month" or "past month" without a named calendar month, use a rolling window: now()
- INTERVAL 30 DAY. For daily/weekly/monthly analysis over a
relative window, keep the same rolling filter in every query; group daily with toStartOfDay, weekly with toStartOfWeek, and make monthly the total for that same rolling window unless the user asks for a multi-month trend. For explicit time windows, preserve or query actual coverage with returned timestamps or MIN/MAX timestamp fields, and state the actual analyzed min/max as the first sentence of the response before any title, heading, or quote list.
Temporal grouping — use built-in functions: Good: toStartOfMonth(nli.record_timestamp), toStartOfWeek(...), toStartOfDay(...) Bad: substring(nli.record_timestamp, 1, 7) -- not supported Bad: DATE_TRUNC('month', ...) -- not supported
Use CONTAINS for all string matching. Never regex, LOWER(), or UPPER(). Good: WHERE nli.source CONTAINS 'Zendesk' Good: WHERE t.category_enum CONTAINS 'COMPLAINT' For case-insensitive search: (fi.content CONTAINS 'login' OR fi.content CONTAINS 'Login')
Sentiment vs Category — different entities, different paths:
- "negative" / "positive" / "neutral" → Sentiment
Path: (fi)-[:HAS_SENTIMENT]->(sp:SentimentPrediction) Filter: sp.label CONTAINS 'negative' CRITICAL: Do NOT query sentiment for Gong/Zoom/Teams/audio sources. These are multi-user conversations — sentiment analysis requires single-user context. If user asks for sentiment on Gong/Zoom: explain the limitation and use category_enum instead.
- "complaints" / "praise" / "improvement" / "help" → Category
Path: (cft)-[:HAS_THEME]->(t:Theme) Filter: t.category_enum CONTAINS 'COMPLAINT' (or 'IMPROVEMENT', 'PRAISE', 'HELP') Works for ALL sources. Never use sentiment (sp.label) as a substitute for category. Never use t.name for category filtering — use t.category_enum.
Theme + Subtheme — use separate MATCH clauses from cft, NOT chained paths: Good: MATCH (cft)-[:HAS_THEME]->(t:Theme) MATCH (cft)-[:HAS_SUBTHEME]->(st:Subtheme) Bad: MATCH (t)-[:HAS_SUBTHEME]->(st) -- follows taxonomy hierarchy, not tags
Citations: always return nli.record_id (NOT fi.record_id — creates broken links). Inequality: use != (not <>). Compiler rejects <>. Exclusion: use NOT IN ['Gong', 'Zoom'] (not chained != ... AND != ...). Boolean: use OR in a single query, not separate queries. Operator precedence: always parenthesize OR groups when combined with AND. Good: WHERE (a OR b OR c) AND t.type != 'MISC' Bad: WHERE a OR b OR c AND t.type != 'MISC' -- AND only applies to last OR term Aliases: use 'total' not 'count' (reserved word). ORDER BY alias name.
Two DIFFERENT hierarchies exist — do not confuse them:
- L1 → L2 → L3: product taxonomy (feature areas). L3 is a finer category, NOT a complaint.
Example: L1="Platform", L2="Integrations", L3="Slack Integration"
- Theme → Subtheme: user opinions/complaints about those features.
Example: Theme="Slack Sync Delays", Subtheme="Sync fails during peak hours"
L3 ≠ Theme. L3 tells you WHICH feature. Theme tells you WHAT users say about it. "What are complaints?" → needs Theme (t.name), NOT L3. "What features exist?" → needs L2/L3.
For exploratory "what" questions, you MUST drill to the opinion level:
When user asks about complaints, issues, pain points, or frustrated users:
- The answer must include t.name (specific complaint themes), not just L2/L3 feature names.
- If combining with features: return BOTH l2.name AND t.name in the same query.
- Do NOT run separate queries — one for features, another for themes. Combine them.
When user asks about dominant themes or top themes:
- The answer must include both t.name AND st.name (subtheme names).
- Join Theme and Subtheme via separate MATCH from cft (not chained).
When user asks about issues in a specific area:
- Scope to the L2 area, but return t.name (theme names) underneath — not l3.name.
When user asks about feature requests or improvements:
- Same pattern as complaints but with IMPROVEMENT category filter.
Skip extra depth for: counts, sentiment, overview questions.
Good: RETURN l2.name AS feature, t.name AS complaint, COUNT(DISTINCT nli.record_id) AS total Bad: RETURN l2.name AS feature, COUNT(DISTINCT nli.record_id) AS total (Theme is joined for filtering but t.name is missing from RETURN — user only sees feature names)
Good: RETURN t.name AS theme, st.name AS subtopic, COUNT(DISTINCT nli.record_id) AS total Bad: RETURN t.name AS theme, COUNT(DISTINCT nli.record_id) AS total (Subtheme not included — user misses the specific sub-issues)
Rule: if you join Theme or Subtheme, ALWAYS include their name in RETURN.
fi.content = AI-generated summaries/chapters. Default for content search. One NLI → 5+ FeedbackInsights (e.g., Gong call → multiple topical chapters). Use for: searching volumes, topic discovery, general queries. nli.content = raw verbatim feedback (full transcripts, 50k+ chars). Use only to inspect candidate records; do not present returned nli.content as final quote text. Decision: "show feedback about X" → fi.content. "exact words" → nli.content. Always prefer taxonomy (Theme/L2/L3) over content text search when possible.
This tool is for finding candidate records, not extracting final user quotes. For quote/verbatim/exact-words requests:
- Query only candidate metadata needed by find_user_quote: DISTINCT
nli.record_id, nli.record_timestamp, nli.source, and fi.content AS summary.
- Do not return fi.content or nli.content as a column named quote/verbatim.
- Do not render fi.content or nli.content as quoted text in the final answer.
- After collecting candidate record_ids, call find_user_quote. If it returns no
validated quotes, do not substitute summaries, paraphrases, or raw content.
Null checks: IS NOT NULL / IS NULL (never != null or = null). List fields (ratings, scores, tags) are stored as lists: Good: WHERE nli.appstore_rating CONTAINS '5' Bad: WHERE nli.appstore_rating = 5 -- fails on list fields Discover available values: RETURN DISTINCT nli.{field} LIMIT 20
Source-specific fields (ratings, tags, account names, custom metadata):
- These are NOT in the base schema. You MUST call search_graph_fields first.
- Qualify with the alias matching each field's entity group returned by
search_graph_fields: NLI/FeedbackRecord fields use nli.{source}_{field} (e.g. nli.appstore_rating, nli.g2_metadata_starrating); Account/User fields use the joined alias (e.g. acc.industry via PROVIDED_BY_ACCOUNT).
- Always discover the exact field name via search_graph_fields before using it in queries.
- For filtering by rating/score: use CONTAINS since values are stored as lists.
- Common NLI source fields: nli.appstore_rating,
nli.appstore_country_full / nli.appstore_country_full_name, nli.g2_metadata_starrating, nli.gong_account_account_name, nli.gong_account_opportunity_name, nli.gong_metadata_title.
- MATCH, OPTIONAL MATCH
- WHERE with CONTAINS, =, !=, >, <, >=, <=, IS NULL, IS NOT NULL, IN, NOT IN, AND, OR, NOT
- RETURN with aliases, ORDER BY, LIMIT, DISTINCT
- COUNT(DISTINCT ...), SUM(), AVG(), MIN(), MAX()
- toStartOfMonth/Week/Day(), now(), INTERVAL, toDateTime(), BETWEEN
- CASE WHEN ... THEN ... ELSE ... END (prefer WITH for bucketed counts)
- HAVING for post-aggregation filtering
- COALESCE() for null fallbacks
Use when user asks to INCLUDE items WITHOUT a relationship (e.g., "include uncategorized"): Good: OPTIONAL MATCH (fi)-[:HAS_TAGS]->(cft)-[:BELONGS_TO_L2]->(l2) RETURN COALESCE(l2.name, 'Uncategorized') AS category, COUNT(DISTINCT nli.record_id) AS total Alternative — run TWO separate queries: Query 1: Count by category (normal MATCH with taxonomy) Query 2: Count total items (MATCH without taxonomy join) Then: Uncategorized = total - sum of categorized Both approaches are valid. Use whichever is simpler for the question. FAIL pattern: using only regular MATCH when user explicitly asks for uncategorized items.
Use CASE for bucketing/categorizing values. For grouped CASE bucket counts, prefer WITH ... CASE ... END AS bucket, then RETURN bucket, COUNT(DISTINCT ...). Good: WITH nli, CASE WHEN nli.source CONTAINS 'Gong' THEN 'Internal' WHEN nli.source CONTAINS 'Zendesk' THEN 'Support' ELSE 'Other' END AS channel RETURN channel, COUNT(DISTINCT nli.record_id) AS total Good: WITH nli, nps, CASE WHEN nps.value <= 6 THEN 'Detractor' WHEN nps.value <= 8 THEN 'Passive' ELSE 'Promoter' END AS category RETURN category, COUNT(DISTINCT nli.record_id) AS total Alternative: run separate queries per bucket with WHERE filters. Both approaches are valid.
- STARTS WITH, ENDS WITH → use CONTAINS instead
- SKIP / OFFSET → no pagination, use LIMIT only
- <> operator → use != instead
- Multiple WITH clauses / WITH + COLLECT chains
- substring(), toString(), DATE_TRUNC(), LOWER(), UPPER(), regex
Use HAVING (not WITH...WHERE): Good: RETURN source, COUNT(DISTINCT nli.record_id) AS total HAVING total > 100 Bad: WITH source, COUNT(...) AS total WHERE total > 100 Use HAVING for: "sources with more than 100 items", "themes with at least 50 complaints", "show only high-volume channels", any threshold on aggregated counts.
- "Deal" = "Opportunity". ALWAYS MATCH (opp:Opportunity), never (d:Deal).
- All deal queries START from (opp:Opportunity). Never from DealReport,
envelopes, or leaf entities.
- Time filter on deals: use opp.close_date (NOT nli.record_timestamp).
- Stage filtering: opp.stage CONTAINS 'Won' or 'Lost'.
- "Recently closed" means ALL closed deals (Won + Lost), not just Won.
Analysis pivot: (opp:Opportunity)-[:IS_ANALYSED_AS]->(dr:DealReport)
DealReport branches by prediction type, each via its own envelope:
- Win/loss: (dr)-[:HAS_WINLOSS_PREDICTION]->(wle:DealWinLossEnvelope)
-[:REFERS_TO_WINLOSS_REASON]->(wlr:WinLossReason) Alt one-hop: (dr)-[:HAS_WIN_LOSS_REASON]->(wlr:WinLossReason). Use wlr.name for reason text. Filter opp.stage CONTAINS 'Won' or 'Lost'.
- Competitors: (dr)-[:HAS_COMPETITOR_PREDICTION]->(dce:DealCompetitorEnvelope)
-[:REFERS_TO_COMPETITOR]->(c:Competitor) Use c.name for competitor name.
- Use Cases: (dr)-[:HAS_USE_CASE_PREDICTION]->(due:DealUseCaseEnvelope)
-[:REFERS_TO_USE_CASE]->(uc:UseCase) Use uc.name for use case name.
- Features: (dr)-[:HAS_FEATURE_PREDICTION]->(dfe:DealFeatureEnvelope)
-[:REFERS_TO_FEATURE]->(df:DealFeature) Filter dfe.mention_type for COMPLAINT/PRAISE. NEVER use L2/L3/Theme/CFT for "features in deals".
Feedback from deals — two DIFFERENT paths, do not confuse them:
- ALL feedback on a deal (any topic):
(opp:Opportunity)-[:HAS_FEEDBACK_RECORD]->(nli:NaturalLanguageInteraction) Return fi.content verbatims, not just metadata. Use DISTINCT for samples.
- Insight-specific feedback (deals lost due to X, competitor mentions, etc.):
envelope pivot → leaf filter + (envelope)-[:HAS_EVIDENCE]->(ev:Evidence)-[:HAS_FEEDBACK]->(nli:NaturalLanguageInteraction) NEVER use HAS_FEEDBACK_RECORD for insight-specific — it returns ALL feedback.
Decline without executing queries: creating/modifying platform artifacts, exporting files, rendering charts, UI navigation, product how-to questions. Never name platform tools (Quantify, dashboards) unless the user mentioned them first. Never expose internal queries (Cypher, SQL) to the user.
Args: cypher_query: Valid Cypher query following the rules above. If the user asked "how many", "all", "total", "count", or "every" — do NOT add a LIMIT clause. description: Plain language description of your information need.
Returns: Structured query results with row count, columns, and data. If source-specific metadata is available, also includes relevant_source_fields.
Current date for relative date resolution: 2026-06-04 UTC.
CITATION URLS: The response carries a `rows_v2 field where every COUNT cell is wrapped as {"value": <number>, "citation": "<dashboard URL>"}. When rows_v2 is populated, rows is empty — use rows_v2 exclusively and wrap the cited number (plus its noun / qualifier when present in prose) inside a markdown link so the URL fires when the reader clicks the claim:
- prose:
[42 complaints](https://dashboard.../citations/...)
- table cell:
[42](https://dashboard.../citations/...)`
Don't fabricate URLs; only use ones returned by this tool.