feat: User interviews tool (#32237)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Michael Matloka
2025-05-15 23:16:19 +02:00
committed by GitHub
parent f6c630a4a5
commit ea02b9fedd
43 changed files with 934 additions and 20 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -102,11 +102,6 @@ await buildInParallel(
deniedPatterns.some((pattern) => pattern.test(args.path))
if (shouldDeny) {
console.log(
'replacing',
args.path,
'with empty module. it is not allowed in the toolbar bundle.'
)
return {
path: args.path,
namespace: 'empty-module',

View File

@@ -66,6 +66,7 @@
"@posthog/products-dashboards": "workspace:*",
"@posthog/products-early-access-features": "workspace:*",
"@posthog/products-experiments": "workspace:*",
"@posthog/products-user-interviews": "workspace:*",
"@posthog/products-feature-flags": "workspace:*",
"@posthog/products-games": "workspace:*",
"@posthog/products-groups": "workspace:*",

View File

@@ -1,6 +1,7 @@
import {
IconAI,
IconArrowUpRight,
IconChat,
IconCursorClick,
IconDashboard,
IconDatabase,
@@ -624,6 +625,14 @@ export const navigation3000Logic = kea<navigation3000LogicType>([
to: urls.earlyAccessFeatures(),
tooltipDocLink: 'https://posthog.com/docs/feature-flags/early-access-feature-management',
},
featureFlags[FEATURE_FLAGS.USER_INTERVIEWS]
? {
identifier: Scene.UserInterviews,
label: 'User interviews',
icon: <IconChat />,
to: urls.userInterviews(),
}
: null,
featureFlags[FEATURE_FLAGS.LLM_OBSERVABILITY]
? {
identifier: 'LLMObservability',

View File

@@ -137,6 +137,7 @@ import {
TeamType,
UserBasicType,
UserGroup,
UserInterviewType,
UserType,
} from '~/types'
@@ -808,6 +809,15 @@ class ApiRequest {
return this.earlyAccessFeatures(teamId).addPathComponent(id)
}
// # User interviews
public userInterviews(teamId?: TeamType['id']): ApiRequest {
return this.environmentsDetail(teamId).addPathComponent('user_interviews')
}
public userInterview(id: UserInterviewType['id'], teamId?: TeamType['id']): ApiRequest {
return this.userInterviews(teamId).addPathComponent(id)
}
// # Surveys
public surveys(teamId?: TeamType['id']): ApiRequest {
return this.projectsDetail(teamId).addPathComponent('surveys')
@@ -2788,6 +2798,21 @@ const api = {
},
},
userInterviews: {
async list(): Promise<PaginatedResponse<UserInterviewType>> {
return await new ApiRequest().userInterviews().get()
},
async get(id: UserInterviewType['id']): Promise<UserInterviewType> {
return await new ApiRequest().userInterview(id).get()
},
async update(
id: UserInterviewType['id'],
data: Pick<UserInterviewType, 'summary'>
): Promise<UserInterviewType> {
return await new ApiRequest().userInterview(id).update({ data })
},
},
surveys: {
async list(
args: {

View File

@@ -264,6 +264,7 @@ export const FEATURE_FLAGS = {
GET_HOG_TEMPLATES_FROM_DB: 'get-hog-templates-from-db', // owner: @meikel #team-cdp
LINK: 'link', // owner: @marconlp #team-link
GAME_CENTER: 'game-center', // owner: everybody
USER_INTERVIEWS: 'user-interviews', // owner: @Twixes @jurajmajerik
LOGS: 'logs', // owner: @david @frank @olly @ross
CSP_REPORTING: 'mexicspo', // owner @pauldambra @lricoy @robbiec
} as const

View File

@@ -34,9 +34,39 @@
margin: 1em 0;
}
h1 {
margin-bottom: 0.25em;
h1,
h2,
h3,
h4 {
margin-bottom: 0.375em;
font-weight: 600;
line-height: 1.2;
}
h1,
h2 {
padding-bottom: 0.375em;
border-bottom-width: 1px;
&:not(:first-child) {
margin-top: 0.75em;
}
}
h1 {
font-size: 1.375rem;
}
h2 {
font-size: 1.125rem;
}
h3 {
font-size: 1rem;
}
h4 {
font-size: 0.875rem;
}
img {

View File

@@ -1,8 +1,8 @@
import './LemonSkeleton.scss'
import clsx from 'clsx'
import { LemonButtonProps } from 'lib/lemon-ui/LemonButton'
import { range } from 'lib/utils'
import { cn } from 'lib/utils/css-classes'
export interface LemonSkeletonProps {
className?: string
@@ -14,7 +14,7 @@ export interface LemonSkeletonProps {
}
export function LemonSkeleton({ className, repeat, active = true, fade = false }: LemonSkeletonProps): JSX.Element {
const content = (
<div className={clsx('LemonSkeleton rounded', !active && 'LemonSkeleton--static', className || 'h-4 w-full')}>
<div className={cn('LemonSkeleton rounded', !active && 'LemonSkeleton--static', className || 'h-4 w-full')}>
{/* The span is for accessibility, but also because @storybook/test-runner smoke tests require content */}
<span>Loading</span>
</div>
@@ -36,15 +36,15 @@ export function LemonSkeleton({ className, repeat, active = true, fade = false }
}
LemonSkeleton.Text = function LemonSkeletonText({ className, ...props }: LemonSkeletonProps) {
return <LemonSkeleton className={clsx('rounded h-6 w-full', className)} {...props} />
return <LemonSkeleton className={cn('rounded h-6 w-full', className)} {...props} />
}
LemonSkeleton.Row = function LemonSkeletonRow({ className, ...props }: LemonSkeletonProps) {
return <LemonSkeleton className={clsx('rounded h-10 w-full', className)} {...props} />
return <LemonSkeleton className={cn('rounded h-10 w-full', className)} {...props} />
}
LemonSkeleton.Circle = function LemonSkeletonCircle({ className, ...props }: LemonSkeletonProps) {
return <LemonSkeleton className={clsx('rounded-full shrink-0', className || 'h-10 w-10')} {...props} />
return <LemonSkeleton className={cn('rounded-full shrink-0', className || 'h-10 w-10')} {...props} />
}
LemonSkeleton.Button = function LemonSkeletonButton({
@@ -54,7 +54,7 @@ LemonSkeleton.Button = function LemonSkeletonButton({
}: LemonSkeletonProps & { size?: LemonButtonProps['size'] }) {
return (
<LemonSkeleton
className={clsx(
className={cn(
'rounded px-3',
size === 'small' && 'h-10',
(!size || size === 'medium') && 'h-10',

View File

@@ -32,6 +32,7 @@ export interface LemonTabsProps<T extends string | number> {
size?: 'small' | 'medium'
'data-attr'?: string
barClassName?: string
className?: string
}
interface LemonTabsCSSProperties extends React.CSSProperties {
@@ -45,6 +46,7 @@ export function LemonTabs<T extends string | number>({
tabs,
barClassName,
size = 'medium',
className,
'data-attr': dataAttr,
}: LemonTabsProps<T>): JSX.Element {
const { containerRef, selectionRef, sliderWidth, sliderOffset, transitioning } = useSliderPositioning<
@@ -58,7 +60,7 @@ export function LemonTabs<T extends string | number>({
return (
<div
className={clsx('LemonTabs', transitioning && 'LemonTabs--transitioning', `LemonTabs--${size}`)}
className={clsx('LemonTabs', transitioning && 'LemonTabs--transitioning', `LemonTabs--${size}`, className)}
// eslint-disable-next-line react/forbid-dom-props
style={
{

View File

@@ -13,7 +13,7 @@ import React, { useRef, useState } from 'react'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
export const LemonTextAreaMarkdown = React.forwardRef<HTMLTextAreaElement, LemonTextAreaProps>(
function _LemonTextAreaMarkdown({ value, onChange, ...editAreaProps }, ref): JSX.Element {
function _LemonTextAreaMarkdown({ value, onChange, className, ...editAreaProps }, ref): JSX.Element {
const { objectStorageAvailable } = useValues(preflightLogic)
const [isPreviewShown, setIsPreviewShown] = useState(false)
@@ -34,6 +34,7 @@ export const LemonTextAreaMarkdown = React.forwardRef<HTMLTextAreaElement, Lemon
<LemonTabs
activeKey={isPreviewShown ? 'preview' : 'write'}
onChange={(key) => setIsPreviewShown(key === 'preview')}
className={className}
tabs={[
{
key: 'write',

View File

@@ -3,6 +3,7 @@
// The imports are preserved between builds, so please update if any are missing or extra.
import {
IconChat,
IconDashboard,
IconGraph,
IconMegaphone,
@@ -57,6 +58,8 @@ export const productScenes: Record<string, () => Promise<any>> = {
MessagingLibrary: () => import('../../products/messaging/frontend/library/MessageLibrary'),
MessagingLibraryTemplate: () => import('../../products/messaging/frontend/library/MessageTemplate'),
RevenueAnalytics: () => import('../../products/revenue_analytics/frontend/RevenueAnalyticsScene'),
UserInterviews: () => import('../../products/user_interviews/frontend/UserInterviews'),
UserInterview: () => import('../../products/user_interviews/frontend/UserInterview'),
}
/** This const is auto-generated, as is the whole file */
@@ -85,6 +88,8 @@ export const productRoutes: Record<string, [string, string]> = {
'messagingLibraryTemplateFromMessage',
],
'/revenue_analytics': ['RevenueAnalytics', 'revenueAnalytics'],
'/user_interviews': ['UserInterviews', 'userInterviews'],
'/user_interviews/:id': ['UserInterview', 'userInterview'],
}
/** This const is auto-generated, as is the whole file */
@@ -140,6 +145,8 @@ export const productConfiguration: Record<string, any> = {
defaultDocsPath: '/docs/web-analytics/revenue-analytics',
activityScope: 'RevenueAnalytics',
},
UserInterviews: { name: 'User interviews', projectBased: true, activityScope: 'UserInterview' },
UserInterview: { name: 'User interview', projectBased: true, activityScope: 'UserInterview' },
}
/** This const is auto-generated, as is the whole file */
@@ -286,6 +293,8 @@ export const productUrls = {
surveys: (tab?: SurveysTabs): string => `/surveys${tab ? `?tab=${tab}` : ''}`,
survey: (id: string): string => `/surveys/${id}`,
surveyTemplates: (): string => '/survey_templates',
userInterviews: (): string => '/user_interviews',
userInterview: (id: string): string => `/user_interviews/${id}`,
webAnalytics: (): string => `/web`,
webAnalyticsWebVitals: (): string => `/web/web-vitals`,
webAnalyticsPageReports: (): string => `/web/page-reports`,
@@ -305,6 +314,7 @@ export const fileSystemTypes = {
notebook: { icon: <IconNotebook />, href: (ref: string) => urls.notebook(ref) },
session_recording_playlist: { icon: <IconRewindPlay />, href: (ref: string) => urls.replayPlaylist(ref) },
survey: { icon: <IconMessage />, href: (ref: string) => urls.survey(ref) },
user_interview: { icon: <IconChat />, href: (ref: string) => urls.userInterview(ref) },
}
/** This const is auto-generated, as is the whole file */
@@ -360,6 +370,7 @@ export const getTreeItemsProducts = (): FileSystemImport[] => [
{ path: 'Revenue settings', iconType: 'handMoney', href: urls.revenueSettings() },
{ path: 'Session replay', href: urls.replay(ReplayTabs.Home), type: 'session_recording_playlist' },
{ path: 'Surveys', type: 'survey', href: urls.surveys() },
{ path: 'User interviews', href: urls.userInterviews(), type: 'user_interview' },
{ path: 'Web analytics', iconType: 'pieChart', href: urls.webAnalytics() },
]
@@ -378,4 +389,5 @@ export const getTreeFilterTypes = (): Record<string, FileSystemFilterType> => ({
notebook: { name: 'Notebooks' },
insight: { name: 'Insights' },
session_recording_playlist: { name: 'Replay playlists' },
user_interview: { name: 'User interviews' },
})

View File

@@ -621,7 +621,7 @@
"type": "object"
},
"AssistantContextualTool": {
"enum": ["search_session_recordings", "generate_hogql_query", "fix_hogql_query"],
"enum": ["search_session_recordings", "generate_hogql_query", "fix_hogql_query", "analyze_user_interviews"],
"type": "string"
},
"AssistantDateRange": {

View File

@@ -108,4 +108,8 @@ export interface AssistantToolCallMessage extends BaseAssistantMessage {
tool_call_id: string
}
export type AssistantContextualTool = 'search_session_recordings' | 'generate_hogql_query' | 'fix_hogql_query'
export type AssistantContextualTool =
| 'search_session_recordings'
| 'generate_hogql_query'
| 'fix_hogql_query'
| 'analyze_user_interviews'

View File

@@ -98,6 +98,8 @@ export enum Scene {
Wizard = 'Wizard',
StartupProgram = 'StartupProgram',
HogFunction = 'HogFunction',
UserInterviews = 'UserInterviews',
UserInterview = 'UserInterview',
Game368 = 'Game368',
}

View File

@@ -198,6 +198,7 @@ export enum ProductKey {
DATA_WAREHOUSE = 'data_warehouse',
DATA_WAREHOUSE_SAVED_QUERY = 'data_warehouse_saved_queries',
EARLY_ACCESS_FEATURES = 'early_access_features',
USER_INTERVIEWS = 'user_interviews',
PRODUCT_ANALYTICS = 'product_analytics',
PIPELINE_TRANSFORMATIONS = 'pipeline_transformations',
PIPELINE_DESTINATIONS = 'pipeline_destinations',
@@ -3560,6 +3561,15 @@ export interface Group {
group_properties: Record<string, any>
}
export interface UserInterviewType {
id: string
created_by: UserBasicType
created_at: string
transcript: string
summary: string
interviewee_emails: string[]
}
export enum ExperimentConclusion {
Won = 'won',
Lost = 'lost',
@@ -4303,6 +4313,7 @@ export enum ActivityScope {
TEAM = 'Team',
ERROR_TRACKING_ISSUE = 'ErrorTrackingIssue',
DATA_WAREHOUSE_SAVED_QUERY = 'DataWarehouseSavedQuery',
USER_INTERVIEW = 'UserInterview',
}
export type CommentType = {

32
pnpm-lock.yaml generated
View File

@@ -620,6 +620,9 @@ importers:
'@posthog/products-surveys':
specifier: workspace:*
version: link:../products/surveys
'@posthog/products-user-interviews':
specifier: workspace:*
version: link:../products/user_interviews
'@posthog/products-web-analytics':
specifier: workspace:*
version: link:../products/web_analytics
@@ -1842,7 +1845,7 @@ importers:
version: link:../../common/eslint_rules
jest:
specifier: '*'
version: 29.7.0(@types/node@18.18.4)(ts-node@10.9.1(@swc/core@1.10.14(@swc/helpers@0.5.15))(@types/node@18.18.4)(typescript@4.9.5))
version: 29.7.0(@types/node@18.18.4)(ts-node@10.9.1(@swc/core@1.11.4(@swc/helpers@0.5.15))(@types/node@18.18.4)(typescript@4.9.5))
kea:
specifier: '*'
version: 3.1.5(react@18.2.0)
@@ -2060,6 +2063,33 @@ importers:
specifier: '*'
version: 18.2.0
products/user_interviews:
dependencies:
'@posthog/icons':
specifier: 0.13.1
version: 0.13.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@types/react':
specifier: '*'
version: 17.0.52
clsx:
specifier: '*'
version: 1.2.1
kea:
specifier: '*'
version: 3.1.5(react@18.2.0)
kea-loaders:
specifier: '*'
version: 3.0.0(kea@3.1.5(react@18.2.0))
kea-router:
specifier: '*'
version: 3.2.1(kea@3.1.5(react@18.2.0))
posthog-js:
specifier: '*'
version: 1.242.2(@rrweb/types@2.0.0-alpha.17)
react:
specifier: '*'
version: 18.2.0
products/web_analytics:
dependencies:
'@posthog/icons':

View File

@@ -3,6 +3,9 @@ from rest_framework_extensions.routers import NestedRegistryItem
import products.data_warehouse.backend.api.fix_hogql as fix_hogql
import products.early_access_features.backend.api as early_access_feature
from products.user_interviews.backend.api import UserInterviewViewSet
from products.editor.backend.api import LLMProxyViewSet, MaxToolsViewSet
from products.messaging.backend.api import MessageTemplatesViewSet
import products.logs.backend.api as logs
from posthog.api import data_color_theme, metalytics, project, wizard
from posthog.api.csp_reporting import CSPReportingViewSet
@@ -19,8 +22,6 @@ from posthog.warehouse.api import (
table,
view_link,
)
from products.editor.backend.api import LLMProxyViewSet, MaxToolsViewSet
from products.messaging.backend.api import MessageTemplatesViewSet
from ..heatmaps.heatmaps_api import HeatmapViewSet, LegacyHeatmapViewSet
from ..session_recordings.session_recording_api import SessionRecordingViewSet
@@ -686,6 +687,13 @@ environments_router.register(
# Logs endpoints
register_grandfathered_environment_nested_viewset(r"logs", logs.LogsViewSet, "environment_logs", ["team_id"])
environments_router.register(
r"user_interviews",
UserInterviewViewSet,
"environment_user_interviews",
["team_id"],
)
environments_router.register(
r"csp-reporting",
CSPReportingViewSet,

View File

@@ -97,6 +97,7 @@
'/home/runner/work/posthog/posthog/products/editor/backend/api/max_tools.py: Warning [MaxToolsViewSet]: could not derive type of path parameter "project_id" because model "ee.models.assistant.Conversation" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/products/logs/backend/api.py: Error [LogsViewSet]: unable to guess serializer. This is graceful fallback handling for APIViews. Consider using GenericAPIView as view base class, if view is under your control. Either way you may want to add a serializer_class (or method). Ignoring view for now.',
'/home/runner/work/posthog/posthog/products/logs/backend/api.py: Warning [LogsViewSet]: could not derive type of path parameter "project_id" because it is untyped and obtaining queryset from the viewset failed. Consider adding a type to the path (e.g. <int:project_id>) or annotating the parameter type with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/products/user_interviews/backend/api.py: Warning [UserInterviewViewSet]: could not derive type of path parameter "project_id" because model "products.user_interviews.backend.models.UserInterview" contained no such field. Consider annotating parameter with @extend_schema. Defaulting to "string".',
'/opt/hostedtoolcache/Python/3.11.9/x64/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py: Warning [QueryViewSet > ModelMetaclass]: Encountered 2 components with identical names "Person" and different classes <class \'str\'> and <class \'posthog.api.person.PersonSerializer\'>. This will very likely result in an incorrect schema. Try renaming one.',
'Warning: encountered multiple names for the same choice set (EffectivePrivilegeLevelEnum). This may be unwanted even though the generated schema is technically correct. Add an entry to ENUM_NAME_OVERRIDES to fix the naming.',
'Warning: encountered multiple names for the same choice set (HrefMatchingEnum). This may be unwanted even though the generated schema is technically correct. Add an entry to ENUM_NAME_OVERRIDES to fix the naming.',

View File

@@ -40,6 +40,7 @@ APIScopeObject = Literal[
"subscription",
"survey",
"user",
"user_interview_DO_NOT_USE", # This is a super alpha product, so only exposing here for internal personal API key access
"webhook",
"logs", # logs product
]

View File

@@ -65,6 +65,7 @@ class AssistantContextualTool(StrEnum):
SEARCH_SESSION_RECORDINGS = "search_session_recordings"
GENERATE_HOGQL_QUERY = "generate_hogql_query"
FIX_HOGQL_QUERY = "fix_hogql_query"
ANALYZE_USER_INTERVIEWS = "analyze_user_interviews"
class AssistantDateRange(BaseModel):

View File

@@ -31,6 +31,7 @@ PRODUCTS_APPS = [
"products.early_access_features",
"products.editor",
"products.revenue_analytics",
"products.user_interviews",
]
INSTALLED_APPS = [

View File

View File

@@ -0,0 +1,219 @@
from functools import cached_property
import json
import re
from uuid import uuid4
import posthoganalytics.ai.openai
from rest_framework import viewsets
from rest_framework.parsers import MultiPartParser, JSONParser
from posthog.api.routing import TeamAndOrgViewSetMixin
from .models import UserInterview
from elevenlabs import ElevenLabs
import posthoganalytics
from rest_framework import serializers
from django.core.files import File
from posthog.api.shared import UserBasicSerializer
from posthoganalytics.ai.openai import OpenAI
elevenlabs_client = ElevenLabs()
class UserInterviewSerializer(serializers.ModelSerializer):
created_by = UserBasicSerializer(read_only=True)
audio = serializers.FileField(write_only=True)
class Meta:
model = UserInterview
fields = (
"id",
"created_by",
"created_at",
"interviewee_emails",
"transcript",
"summary",
"audio",
)
read_only_fields = ("id", "created_by", "created_at", "transcript")
def create(self, validated_data):
request = self.context["request"]
validated_data["created_by"] = request.user
validated_data["team_id"] = self.context["team_id"]
audio = validated_data.pop("audio")
validated_data["transcript"] = self._transcribe_audio(audio, validated_data["interviewee_emails"])
validated_data["summary"] = self._summarize_transcript(validated_data["transcript"])
return super().create(validated_data)
def _transcribe_audio(self, audio: File, interviewee_emails: list[str]):
transcript = elevenlabs_client.speech_to_text.convert(
model_id="scribe_v1",
file=audio,
num_speakers=10, # Maximum number of speakers, not expected one
diarize=True,
tag_audio_events=False,
additional_formats=json.dumps( # type: ignore
[
{
"format": "txt",
"include_timestamps": False,
"segment_on_silence_longer_than_s": 10,
}
]
),
)
transcript_text = transcript.additional_formats[0].content.strip() # type: ignore
speaker_mapping = self._attempt_to_map_speaker_names(transcript_text, interviewee_emails)
if speaker_mapping:
for speaker_marker, speaker_name in speaker_mapping.items():
transcript_text = transcript_text.replace(speaker_marker, speaker_name)
formatted_transcript_text = re.sub(r"\[(.+)\]", "#### \\1", transcript_text)
else:
# Always fall back to formatting speaker numbers if we can't map names
formatted_transcript_text = re.sub(r"\[speaker_(\d+)\]", "#### Speaker \\1", transcript_text)
return formatted_transcript_text
def _attempt_to_map_speaker_names(self, transcript: str, interviewee_emails: list[str]) -> dict[str, str] | None:
participant_emails_joined = "\n".join(f"- {email}" for email in interviewee_emails)
assignment_response = OpenAI(posthog_client=posthoganalytics.default_client).responses.create( # type: ignore
model="gpt-4.1-mini",
posthog_trace_id=self._ai_trace_id,
posthog_distinct_id=self.context["request"].user.distinct_id,
input=[
{
"role": "system",
"content": """
Your task is to map speakers in a transcript to the actual names of the people who spoke. Each speaker is identified by a number.
Use clues such as who the speaker is calling out (e.g. they wouldn't greet themselves) or what they're talking about (e.g. how they use company names).
Your output should be a JSON mapping between "speaker_<n>" and "<speaker name>".
<handling_ambiguity>
- Use just the person's display name if available, otherwise use their email. If two people with the same full name are present, include their email to disambiguate.
- Likely many of the participants have spoken, but not necessarily all of them.
- Keep in mind it's possible there were additional unexpected participants (though not that likely).
- The transcript is not going to be perfect, so some names in the transcript may be slightly mangled compared to display names in participant emails.
E.g. the transcript may contain that an interviewer greeted "Jon", but if the participant emails only have a "John", it's safe to assume that the interviewer was talking to John.
- If most of the speakers are entirely obvious, but only a small subset isn't, mark the unidentified speakers' names as "Unknown #1 (<candidate_1> or <candidate_2>)" etc. Don't leave any speaker unmarked.
If however you cannot infer a reliable mapping for most speakers (the transcript has no useful information or is too chaotic), return simply: null.
</handling_ambiguity>
<example>
As an example, for transcript:
<participant_emails>
- Michael F. Doe <michael@x.com>
- Steve Jobs <steve@apple.com>
</participant_emails>
<transcript>
[speaker_0]
Hi Michael! How big is your company?
[speaker_1]
Hey! We're about 200 people.
[speaker_0]
That's great!
</transcript>
Your output should be:
{
"speaker_0": "Steve Jobs",
"speaker_1": "Michael F. Doe"
}
</example>
<output_format>
Output must always be valid JSON - either an object or null.
</output_format>
""".strip(),
},
{
"role": "user",
"content": f"""
Map the speakers in the following transcript:
<participant_emails>
{participant_emails_joined}
</participant_emails>
<transcript>
{transcript}
</transcript>
""".strip(),
},
],
)
try:
return json.loads(assignment_response.output_text)
except json.JSONDecodeError:
posthoganalytics.capture_exception()
return None
def _summarize_transcript(self, transcript: str):
summary_response = OpenAI(posthog_client=posthoganalytics.default_client).responses.create( # type: ignore
model="gpt-4.1-mini",
posthog_trace_id=self._ai_trace_id,
posthog_distinct_id=self.context["request"].user.distinct_id,
input=[
{
"role": "system",
"content": """
You are an expert product manager, and your sole task is to summarizes user interviews ran by our team.
""".strip(),
},
{
"role": "user",
"content": f"""
I interviewed a user to gather insights about our product. The goal is to capture the customer's feedback, experiences, and suggestions in a detailed and organized manner.
The notes should be comprehensive but focused, allowing for the detailed documentation of both qualitative insights and actionable items.
Pull out direct quotes and figures whenever relevant.
Because no better transcript is available, you should still do your best to summarize the interview.
<summary_format>
## User background
Capture relevant details about the user, including their role, experience, and how they interact with your product or service.
Note down here any existing solutions or workarounds they use.
## Current product usage
Document how the user is currently using the product, including frequency of use, key features used, and any specific use cases.
## Positive feedback and pain points
Summarize the positive feedback the user provided, as well as any pain points or challenges they are experiencing with the product.
## Impact of the product
Record the impact the product has had on the user's work or life, including any improvements or changes it has enabled.
## Next steps and follow-up
Record the agreed-upon next steps, including any additional actions that need to be taken, follow-up tasks, and who is responsible for them.
</summary_format>
<transcript>
{transcript}
</transcript>
""".strip(),
},
],
)
return summary_response.output_text
@cached_property
def _ai_trace_id(self):
return str(uuid4())
class UserInterviewViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet):
scope_object = "user_interview_DO_NOT_USE"
queryset = UserInterview.objects.order_by("-created_at").select_related("created_by").all()
serializer_class = UserInterviewSerializer
parser_classes = [MultiPartParser, JSONParser]

View File

@@ -0,0 +1,66 @@
from ee.hogai.tool import MaxTool
from pydantic import BaseModel, Field
from openai import OpenAI
from typing import Any
from .models import UserInterview
class AnalyzeUserInterviewsArgs(BaseModel):
analysis_angle: str = Field(
description="How to analyze the interviews based on user's question (e.g. 'Find common pain points', 'Identify feature requests', etc.)"
)
class AnalyzeUserInterviewsTool(MaxTool):
name: str = "analyze_user_interviews"
description: str = "Analyze all user interviews from a specific angle to find patterns and insights"
thinking_message: str = "Analyzing user interviews"
root_system_prompt_template: str = "Since the user is currently on the user interviews page, you should lean towards the `analyze_user_interviews` when it comes to any questions about users or customers."
args_schema: type[BaseModel] = AnalyzeUserInterviewsArgs
def _run_impl(self, analysis_angle: str) -> tuple[str, Any]:
# Get all interviews for the current team
interviews = UserInterview.objects.filter(team_id=self._team_id).order_by("-created_at")
if not interviews:
return "No user interviews found to analyze.", None
# Prepare interview summaries for analysis
interview_summaries = []
for interview in interviews:
if interview.summary:
interview_summaries.append(f"Interview from {interview.created_at}:\n{interview.summary}\n")
if not interview_summaries:
return "No interview summaries found to analyze.", None
interview_summaries = "\n\n".join(interview_summaries)
# Use GPT to analyze the summaries
analysis_response = OpenAI().responses.create(
model="gpt-4.1-mini",
input=[
{
"role": "system",
"content": """
You are an expert product manager analyzing user interviews. Your task is to analyze multiple interview summaries and provide insights based on the requested analysis angle.
Focus on finding patterns, common themes, and actionable insights.
""".strip(),
},
{
"role": "user",
"content": f"""
Please analyze these user interview summaries from the following angle:
{analysis_angle}
<interview_summaries>
{interview_summaries}
</interview_summaries>
Provide a structured analysis with clear sections and bullet points where appropriate. Keep it very concise though. Avoid fluff, just give the facts to answer the question.
""".strip(),
},
],
)
return analysis_response.output_text, None

View File

@@ -0,0 +1,29 @@
import re
from django.db import models
from posthog.models.team import Team
from django.contrib.postgres.fields import ArrayField
from posthog.models.utils import UUIDModel, CreatedMetaFields
from django.core import validators
from django.utils.deconstruct import deconstructible
@deconstructible
class EmailWithDisplayNameValidator:
# In "Michael (some guy) <michael@x.com>" display_name_regex's group 1 matches "Michael"
# (round brackets are comments according to RFC #822, content in there is ignored), and group 2 matches "michael@x.com"
display_name_regex = r"([^(]+) <(.+)>$"
def __call__(self, value):
display_name_match = re.match(self.display_name_regex, value)
if display_name_match:
value = display_name_match.group(2).strip()
return validators.validate_email(value)
class UserInterview(UUIDModel, CreatedMetaFields):
team = models.ForeignKey(Team, on_delete=models.CASCADE)
interviewee_emails = ArrayField(
models.CharField(max_length=254, validators=[EmailWithDisplayNameValidator()]), default=list
)
transcript = models.TextField(blank=True)
summary = models.TextField(blank=True)

View File

@@ -0,0 +1,157 @@
import { IconCheck, IconPencil, IconX } from '@posthog/icons'
import { LemonButton, LemonSkeleton, LemonTag, LemonTextAreaMarkdown } from '@posthog/lemon-ui'
import { useAsyncActions, useValues } from 'kea'
import { NotFound } from 'lib/components/NotFound'
import { PageHeader } from 'lib/components/PageHeader'
import { dayjs } from 'lib/dayjs'
import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown'
import { LemonWidget } from 'lib/lemon-ui/LemonWidget/LemonWidget'
import posthog from 'posthog-js'
import { useState } from 'react'
import { PersonDisplay } from 'scenes/persons/PersonDisplay'
import { SceneExport } from 'scenes/sceneTypes'
import { UserInterviewType } from '~/types'
import { userInterviewLogic } from './userInterviewLogic'
export const scene: SceneExport = {
component: UserInterview,
logic: userInterviewLogic,
paramsToProps: ({ params: { id } }): (typeof userInterviewLogic)['props'] => ({ id }),
}
export function UserInterview(): JSX.Element {
const { userInterview, userInterviewLoading } = useValues(userInterviewLogic)
const { updateUserInterview } = useAsyncActions(userInterviewLogic)
const [summaryInEditing, setSummaryInEditing] = useState<string | null>(null)
if (userInterviewLoading && !userInterview) {
return (
<div className="@container">
<PageHeader caption={<LemonSkeleton.Text className="w-48 h-4" />} />
<div className="grid grid-cols-1 items-start gap-4 @4xl:grid-cols-3">
<LemonWidget title="Summary" className="col-span-2">
<div className="space-y-1.5 p-3">
<LemonSkeleton.Text className="h-6 w-[20%]" />
<LemonSkeleton.Text className="h-3 w-[60%]" />
<LemonSkeleton.Text className="h-3 w-[70%]" />
<LemonSkeleton.Text className="h-3 w-[80%]" />
<LemonSkeleton.Text className="h-3 w-[40%]" />
<LemonSkeleton.Text className="h-3 w-[55%]" />
<LemonSkeleton.Text className="h-3 w-[65%]" />
</div>
</LemonWidget>
<LemonWidget title="Transcript" className="col-span-1">
<div className="space-y-1.5 p-3">
<LemonSkeleton.Text className="h-3 w-[80%]" />
<LemonSkeleton.Text className="h-3 w-[40%]" />
<LemonSkeleton.Text className="h-3 w-[60%]" />
<LemonSkeleton.Text className="h-3 w-[70%]" />
<LemonSkeleton.Text className="h-3 w-[80%]" />
<LemonSkeleton.Text className="h-3 w-[40%]" />
<LemonSkeleton.Text className="h-3 w-[60%]" />
<LemonSkeleton.Text className="h-3 w-[70%]" />
</div>
</LemonWidget>
</div>
</div>
)
}
if (!userInterview) {
return <NotFound object="user interview" />
}
return (
<div className="@container">
<PageHeader caption={<InterviewMetadata interview={userInterview} />} />
<div className="grid grid-cols-1 items-start gap-4 @4xl:grid-cols-3">
<LemonWidget
title="Summary"
className="col-span-2"
actions={
summaryInEditing !== null ? (
<>
<LemonButton
size="xsmall"
icon={<IconX />}
tooltip="Discard changes"
onClick={() => setSummaryInEditing(null)}
disabledReason={userInterviewLoading ? 'Saving…' : undefined}
/>
<LemonButton
size="xsmall"
icon={<IconCheck />}
tooltip="Save"
onClick={() => {
updateUserInterview({ summary: summaryInEditing })
.then(() => {
setSummaryInEditing(null)
})
.catch((e) => posthog.captureException(e))
}}
loading={userInterviewLoading}
/>
</>
) : (
<LemonButton
size="xsmall"
icon={<IconPencil />}
onClick={() => setSummaryInEditing(userInterview.summary || '')}
/>
)
}
>
{summaryInEditing !== null ? (
<LemonTextAreaMarkdown
value={summaryInEditing}
onChange={(newValue) => setSummaryInEditing(newValue)}
className="pb-2 px-3"
/>
) : (
<LemonMarkdown className="p-3">
{userInterview.summary || '_No summary available._'}
</LemonMarkdown>
)}
</LemonWidget>
<div className="col-span-1 flex flex-col gap-y-4">
<LemonWidget title="Participants">
<div className="p-3 flex flex-col gap-y-2">
{userInterview.interviewee_emails.map((interviewee_email) => (
<PersonDisplay
key={interviewee_email}
person={{
properties: {
email: interviewee_email,
},
distinct_id: interviewee_email,
}}
withIcon
/>
))}
</div>
</LemonWidget>
<LemonWidget title="Transcript">
<LemonMarkdown className="p-3">
{userInterview.transcript || '_No transcript available._'}
</LemonMarkdown>
</LemonWidget>
</div>
</div>
</div>
)
}
function InterviewMetadata({ interview }: { interview: UserInterviewType }): JSX.Element {
return (
<header className="flex gap-x-2 gap-y-1 flex-wrap items-center">
{interview.created_at && (
<LemonTag className="bg-bg-light">
Created: {dayjs(interview.created_at).format('YYYY-MM-DD HH:mm')}
</LemonTag>
)}
</header>
)
}

View File

@@ -0,0 +1,77 @@
import { IconDownload } from '@posthog/icons'
import { LemonButton, LemonTable, LemonTableColumn } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { PhonePairHogs } from 'lib/components/hedgehogs'
import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction'
import { createdAtColumn, createdByColumn } from 'lib/lemon-ui/LemonTable/columnUtils'
import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink'
import { MaxTool } from 'scenes/max/MaxTool'
import { SceneExport } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
import { userLogic } from 'scenes/userLogic'
import { ProductKey, UserInterviewType } from '~/types'
import { userInterviewsLogic } from './userInterviewsLogic'
export const scene: SceneExport = {
component: UserInterviews,
logic: userInterviewsLogic,
}
export function UserInterviews(): JSX.Element {
const { userInterviews, userInterviewsLoading } = useValues(userInterviewsLogic)
const { updateHasSeenProductIntroFor } = useActions(userLogic)
return (
<>
<ProductIntroduction
productName="User interviews"
productKey={ProductKey.USER_INTERVIEWS}
thingName="user interview"
description="Make full use of user interviews by recording them with PostHog."
customHog={PhonePairHogs}
isEmpty={!userInterviewsLoading && userInterviews.length === 0}
actionElementOverride={
<LemonButton
type="primary"
icon={<IconDownload />}
onClick={() => updateHasSeenProductIntroFor(ProductKey.USER_INTERVIEWS, true)}
to="https://posthog.com/recorder"
data-attr="install-recorder"
>
Install PostHog Recorder
</LemonButton>
}
/>
<MaxTool
name="analyze_user_interviews"
displayName="Analyze user interviews"
context={{}}
callback={() => {
// No need to handle structured output for this tool
}}
>
<LemonTable
loading={userInterviewsLoading}
columns={[
{
title: 'Interviewees',
key: 'interviewees',
render: (_, row) => (
<LemonTableLink
title={row.interviewee_emails.join(', ')}
to={urls.userInterview(row.id)}
/>
),
},
createdAtColumn() as LemonTableColumn<UserInterviewType, keyof UserInterviewType | undefined>,
createdByColumn() as LemonTableColumn<UserInterviewType, keyof UserInterviewType | undefined>,
]}
dataSource={userInterviews}
loadingSkeletonRows={5}
/>
</MaxTool>
</>
)
}

View File

@@ -0,0 +1,59 @@
import { afterMount, kea, key, path, props, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import api from 'lib/api'
import { urls } from 'scenes/urls'
import { Breadcrumb, UserInterviewType } from '~/types'
import type { userInterviewLogicType } from './userInterviewLogicType'
import { userInterviewsLogic } from './userInterviewsLogic'
export interface UserInterviewLogicProps {
id: string
}
export const userInterviewLogic = kea<userInterviewLogicType>([
path(['products', 'user_interviews', 'frontend', 'userInterviewLogic']),
props({} as UserInterviewLogicProps),
key((props) => props.id),
loaders(({ props }) => ({
userInterview: [
userInterviewsLogic.findMounted()?.values.userInterviews.find((interview) => interview.id === props.id) ||
null,
{
loadUserInterview: async (id: string): Promise<UserInterviewType | null> => {
try {
return await api.userInterviews.get(id)
} catch {
return null
}
},
updateUserInterview: async (data: { summary: string }): Promise<UserInterviewType> => {
return await api.userInterviews.update(props.id, data)
},
},
],
})),
selectors(({ props }) => ({
breadcrumbs: [
(s) => [s.userInterview],
(userInterview): Breadcrumb[] => [
{
key: 'UserInterviews',
name: 'User interviews',
path: urls.userInterviews(),
},
{
key: props.id,
name: userInterview?.interviewee_emails?.join(', '),
path: urls.userInterview(props.id),
},
],
],
})),
afterMount(({ actions, props }) => {
if (props.id) {
actions.loadUserInterview(props.id)
}
}),
])

View File

@@ -0,0 +1,36 @@
import { afterMount, kea, path, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import api from 'lib/api'
import { urls } from 'scenes/urls'
import { Breadcrumb, UserInterviewType } from '~/types'
import type { userInterviewsLogicType } from './userInterviewsLogicType'
export const userInterviewsLogic = kea<userInterviewsLogicType>([
path(['products', 'user_interviews', 'frontend', 'userInterviewsLogic']),
loaders({
userInterviews: {
__default: [] as UserInterviewType[],
loadUserInterviews: async () => {
const response = await api.userInterviews.list()
return response.results
},
},
}),
selectors({
breadcrumbs: [
() => [],
(): Breadcrumb[] => [
{
key: 'UserInterviews',
name: 'User interviews',
path: urls.userInterviews(),
},
],
],
}),
afterMount(({ actions }) => {
actions.loadUserInterviews()
}),
])

View File

@@ -0,0 +1,46 @@
import { IconChat } from '@posthog/icons'
import { urls } from 'scenes/urls'
import { ProductManifest } from '../../frontend/src/types'
export const manifest: ProductManifest = {
name: 'User interviews',
scenes: {
UserInterviews: {
name: 'User interviews',
import: () => import('./frontend/UserInterviews'),
projectBased: true,
activityScope: 'UserInterview',
},
UserInterview: {
name: 'User interview',
import: () => import('./frontend/UserInterview'),
projectBased: true,
activityScope: 'UserInterview',
},
},
routes: {
'/user_interviews': ['UserInterviews', 'userInterviews'],
'/user_interviews/:id': ['UserInterview', 'userInterview'],
},
urls: {
userInterviews: (): string => '/user_interviews',
userInterview: (id: string): string => `/user_interviews/${id}`,
},
fileSystemTypes: {
user_interview: {
icon: <IconChat />,
href: (ref: string) => urls.userInterview(ref),
},
},
treeItemsProducts: [
{
path: 'User interviews',
href: urls.userInterviews(),
type: 'user_interview',
},
],
fileSystemFilterTypes: {
user_interview: { name: 'User interviews' },
},
}

View File

@@ -0,0 +1,55 @@
# Generated by Django 4.2.18 on 2025-05-15 18:20
from django.conf import settings
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import posthog.models.utils
import products.user_interviews.backend.models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("posthog", "0733_file_system_shortcut"),
]
operations = [
migrations.CreateModel(
name="UserInterview",
fields=[
("created_at", models.DateTimeField(auto_now_add=True)),
(
"id",
models.UUIDField(
default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False
),
),
(
"interviewee_emails",
django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
max_length=254,
validators=[products.user_interviews.backend.models.EmailWithDisplayNameValidator()],
),
default=list,
size=None,
),
),
("transcript", models.TextField(blank=True)),
("summary", models.TextField(blank=True)),
(
"created_by",
models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
),
),
("team", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="posthog.team")),
],
options={
"abstract": False,
},
),
]

View File

@@ -0,0 +1 @@
0001_initial

View File

@@ -0,0 +1,13 @@
{
"name": "@posthog/products-user-interviews",
"peerDependencies": {
"@posthog/icons": "*",
"@types/react": "*",
"posthog-js": "*",
"clsx": "*",
"kea": "*",
"kea-loaders": "*",
"kea-router": "*",
"react": "*"
}
}

View File

@@ -135,6 +135,7 @@ dependencies = [
"pyyaml==6.0.1",
"tenacity>=8.4.2",
"chdb>=3.1.2",
"elevenlabs>=1.58.1",
]
[dependency-groups]

View File

@@ -30,6 +30,7 @@ depends_on = [
"products.early_access_features",
"products.editor",
"products.revenue_analytics",
"products.user_interviews",
]
[[modules]]

19
uv.lock generated
View File

@@ -1566,6 +1566,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/cd/84c44a5d435f6544e58a9b138305f59bca232157ae4ecb658f9787f87d1c/drf_spectacular-0.27.2-py3-none-any.whl", hash = "sha256:b1c04bf8b2fbbeaf6f59414b4ea448c8787aba4d32f76055c3b13335cf7ec37b", size = 102930 },
]
[[package]]
name = "elevenlabs"
version = "1.58.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
{ name = "pydantic-core" },
{ name = "requests" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/83/fd165b38a69a4a40746926a908ea92e456a0e0dd5b6038836c9cc94a3487/elevenlabs-1.58.1.tar.gz", hash = "sha256:e9f723a528c1bbd80605e639e858f7a58f204860faa9417305a4083508c7c0fb", size = 185830 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/1f/95e2e56e6c139c497b4f1d2a546093e90cecbdf156766260f9220ba6c4f7/elevenlabs-1.58.1-py3-none-any.whl", hash = "sha256:2163054cb36b0aa70079f47ef7c046bf8668d5d183fd616b1c1c11d3996a50ce", size = 473568 },
]
[[package]]
name = "et-xmlfile"
version = "1.1.0"
@@ -3719,6 +3736,7 @@ dependencies = [
{ name = "drf-exceptions-hog" },
{ name = "drf-extensions" },
{ name = "drf-spectacular" },
{ name = "elevenlabs" },
{ name = "geoip2" },
{ name = "google-cloud-bigquery" },
{ name = "google-genai" },
@@ -3917,6 +3935,7 @@ requires-dist = [
{ name = "drf-exceptions-hog", specifier = "==0.4.0" },
{ name = "drf-extensions", specifier = "==0.7.0" },
{ name = "drf-spectacular", specifier = "==0.27.2" },
{ name = "elevenlabs", specifier = ">=1.58.1" },
{ name = "geoip2", specifier = "==4.6.0" },
{ name = "google-cloud-bigquery", specifier = "==3.26" },
{ name = "google-genai", specifier = "==1.10.0" },